Client Unit Test Overhaul

04/18/2026

Problem Statement

The client currently has 37 BDD/Cucumber integration tests (.steps.js) covering UI flows end-to-end, but virtually zero unit tests for computational JavaScript. This means:

  • Helper functions, utilities, and data-transformation logic have no regression safety net.
  • Bugs in pure functions (version comparison, price calculation, sport-period labeling, etc.) can only be caught by running full integration suites or by manual QA.
  • Code coverage for the global/, utils/, and services/ layers is effectively 0%.

Proposed Solution

Add a comprehensive suite of Jest unit tests targeting all computational JavaScript — pure functions, utility classes, custom hooks, and the request/service layer — without touching any existing implementation code. The goal is 80%+ line/branch coverage on every file in scope.

Tests should:

  • Use table-driven (parameterized) patterns wherever a function maps inputs → outputs.
  • Live in a __tests__/ sibling directory next to the file under test, named <FileName>.test.js.
  • Rely on jest-fetch-mock (already installed) for HTTP-layer tests; use jest.useFakeTimers() for timer-dependent logic.
  • Mock side-effectful dependencies (@capacitor/core, localStorage, DOM APIs) at the module boundary, not inline.

Existing BDD integration tests are not modified or removed — unit tests complement them.

Architectural & Technical Details

Test File Location Convention

client/src/
  global/
    __tests__/
      util.test.js
      Cache.test.js
    request/
      __tests__/
        request.test.js
        RequestDeduplicator.test.js
        DebounceUtility.test.js
        bet.test.js
        auth.test.js
        user.test.js
        restaurant.test.js
  utils/
    __tests__/
      gamePeriodLabel.test.js
      env.test.js
      notificationUtils.test.js
      appDeeplink.test.js

Table-Driven Test Pattern (reference)

// Example: table-driven test for a pure function
describe('formatName', () => {
  const cases = [
    { input: 'John Doe',       expected: 'John D.' },
    { input: 'Alice',          expected: 'Alice'   },
    { input: '  Bob Smith  ',  expected: 'Bob S.'  },
    { input: '',               expected: ''        },
    { input: null,             expected: ''        },
    { input: 42,               expected: ''        },
  ];

  test.each(cases)('formatName($input) → $expected', ({ input, expected }) => {
    expect(formatName(input)).toBe(expected);
  });
});

Jest Coverage Flags

Add to package.json jest config to enforce thresholds once phases are complete:

"coverageThreshold": {
  "global/util.js":               { "lines": 80 },
  "global/request/request.js":    { "lines": 80 },
  "utils/gamePeriodLabel.js":     { "lines": 80 }
}

Next Steps

Implementation is broken into five sequential phases. Each phase ships as its own PR against develop.


Phase 1 — Pure Utility Functions (global/util.js)

Target file: client/src/global/util.js
Target test file: client/src/global/__tests__/util.test.js

This file contains the highest-value pure functions in the codebase. No network calls, no side effects — ideal first target.

Function Test Focus Table-Driven?
capitalize Lowercase, uppercase, mixed, single char, empty Yes
formatName Full name, single name, null, non-string, extra spaces Yes
parseFormattedAddress Valid addresses, missing parts, invalid format, edge whitespace Yes
calculateGrandTotal Delivery vs. in-house, zero subtotal, rounding behavior Yes
getValidVenueLogo http/https URLs, data URIs, relative paths, null, empty, whitespace Yes
checkUpdateRequired Current > required, current < required, equal, mismatched format prefix, NaN Yes
getPromoImageUrl Empty string, null, valid filename Yes
areLocationsInRange Mocked window.google.maps.geometry.spherical.computeDistanceBetween No
fetchUserName Mock queryUsers — success, empty result, error No

Mocking strategy:

  • Mock ../../utils/env to control getCdnHost / isProduction returns.
  • Mock ./request/user to isolate fetchUserName from network.
  • Stub window.google.maps for geolocation helpers.

Phase 2 — Request Layer Classes (global/request/)

Targets:

  • client/src/global/request/request.js__tests__/request.test.js
  • client/src/global/request/RequestDeduplicator.js__tests__/RequestDeduplicator.test.js
  • client/src/global/request/DebounceUtility.js__tests__/DebounceUtility.test.js

RequestUtility

Method Scenario Assertion
constructor Null URL Throws 'Service URL must be provided'
constructor Valid URL + options Sets this.url, this.default.method, this.default.endpoint
request 200 OK response Returns parsed JSON
request Non-OK response Throws Error with message from body
request Uses default endpoint when none provided Fetch called with correct URL
authorizedRequest Sets Authorization: Bearer … header Header present in fetch call
authorizedRequest Non-OK response Throws with error.response.status set
authorizedRequestXml 200 OK XML text Returns DOMParser document
authorizedRequestXml Non-OK response Throws

Use jest-fetch-mock (fetchMock.mockResponseOnce(...)) for all HTTP assertions.

RequestDeduplicator

Method Scenario Assertion
execute First call with key Executes requestFn, stores promise
execute Second call same key before resolve Returns same promise (deduplication)
execute After resolve Removes key from map
execute Timeout exceeded Rejects with 'Request timeout: <key>'
clear Called with pending requests Map is empty
clearForUser Key contains userId Only matching keys removed
isPending After execute, before resolve Returns true
isPending After resolve Returns false

Use jest.useFakeTimers() for timeout tests.

DebounceUtility

Method Scenario Assertion
debounce Called once fn called after delay
debounce Called twice within delay fn called only once (last call wins)
cancel Called before delay fires fn never called
clear Multiple pending keys All cleared, none fire

Phase 3 — Game & Sport Logic (utils/gamePeriodLabel.js, global/request/bet.js)

formatGamePeriodLabel (utils/gamePeriodLabel.js)

Table-driven across all sport keys and edge cases:

Input Expected
null null
{ status: 'final' } null
{ status: 'live', sport: 'nfl', gameProgress: 1 } '1st Quarter'
{ status: 'live', sport: 'nfl', gameProgress: 4 } '4th Quarter'
{ status: 'live', sport: 'nfl', gameProgress: 5 } 'Overtime'
{ status: 'live', sport: 'nba', gameProgress: 2 } '2nd Quarter'
{ status: 'live', sport: 'nhl', gameProgress: 1 } '1st Period'
{ status: 'live', sport: 'nhl', gameProgress: 3 } '3rd Period'
{ status: 'live', sport: 'nhl', gameProgress: 4 } 'Overtime'
{ status: 'live', sport: 'mlb', gameProgress: 7 } '7th Inning'
{ status: 'live', sport: 'mlb', gameProgress: 11 } '11th Inning'
{ status: 'live', sport: 'mlb', gameProgress: 12 } '12th Inning'
{ status: 'live', sport: 'mlb', gameProgress: 13 } '13th Inning'
{ status: 'live', sport: 'soccer', gameProgress: 1 } '1st Half'
{ status: 'live', sport: 'soccer', gameProgress: 2 } '2nd Half'
{ status: 'live', sport: 'ncaaf', gameProgress: 2 } '2nd Quarter' (ncaaf → cfb)
{ gameStatus: 'live', sport: 'nfl', game_progress: 3 } '3rd Quarter' (alternate field names)
{ status: 'live', sport: 'nfl', gameProgress: 0 } null
{ status: 'live', sport: 'nfl', gameProgress: -1 } null
{ status: 'live', sport: 'unknown', gameProgress: 2 } null

Also test ordinal (unexported but implicitly covered) via edge cases: 11th, 12th, 13th, 21st, 22nd, 23rd.

bet.js pure functions

Function Table-Driven Cases
getSoccerTeamAbbreviation 'FC Barcelona''Bar'; 'Real Madrid''Mad'; 'Chelsea''Che'; 'AC Milan''Mil'
getSportLeague Each entry in LEAGUE_BE_FE_MAP; unknown league with team fallback; both null
getTeamDisplayColor Each sport’s home/away color constant; unknown sport → DEFAULT_COLOR

Mock ../../utils/env and ./request to prevent network calls.


Phase 4 — Utility Modules (utils/)

env.js (__tests__/env.test.js)

Mock @capacitor/core and process.env to control environment:

Scenario Assertion
REACT_APP_ENV=production isProduction === true
REACT_APP_ENV=development isProduction === false unless overridden
Native Android platform isProduction === true
Prod hostname (getfoodfight.link) isProdWeb === true
Dev hostname (localhost) isProdWeb === false
getCdnHost in prod Returns prod CDN URL
getCdnHost in dev Returns dev CDN URL
getStripePublishableKey in prod Returns REACT_APP_STRIPE_LIVE_KEY
getStripePublishableKey in dev Returns REACT_APP_STRIPE_TEST_KEY

Note: env.js uses module-level IIFE execution. Tests must use jest.resetModules() + jest.mock() before each re-import to reset environment state.

notificationUtils.js (__tests__/notificationUtils.test.js)

Function Scenario Assertion
checkNotificationPermission Android platform Returns false
checkNotificationPermission Plugin not available Returns false
checkNotificationPermission Permission granted Returns true
checkNotificationPermission Permission denied Returns false
checkNotificationPermission Plugin throws Returns false
hasBeenPromptedForNotifications Key absent Returns false
hasBeenPromptedForNotifications Key = 'true' Returns true
hasBeenPromptedForNotifications localStorage throws Returns false
markNotificationPromptShown Normal Sets 'notificationPromptShown' to 'true'
markNotificationPromptShown localStorage throws Does not throw
checkForCustomerIOMessage window.Gist absent Returns false
checkForCustomerIOMessage currentMessages empty Returns false
checkForCustomerIOMessage currentMessages.length > 0 Returns true

Use jest.useFakeTimers() for waitForBackendModalDismissal timeout behavior.

appDeeplink.js (__tests__/appDeeplink.test.js)

Table-driven over known deeplink URL patterns → expected parsed output objects (route, params).


Phase 5 — Custom Hooks (global/)

Tools: @testing-library/react renderHook, mock context providers.

Hook Key Scenarios
useFetch Loading state → success; loading → error; re-fetch on dependency change
useProfile Cache hit (no network call); cache miss (fetches and caches); unauthenticated
useUsers Successful query returns user array; empty result; network error
useCarousel Initial index, next() advances, prev() wraps, boundary conditions
useHours Open now, closed, edge cases near midnight
usePromoCounts Returns count from API; handles zero

Mock all HTTP calls via jest.mock() on the corresponding request module. Wrap hooks in minimal context providers where AppContext is consumed.


Pre-work Requirements

Before starting any phase:

  • Commit discipline: After each test file is written and all tests in that file pass (npm test <file>), commit immediately with a descriptive, human-readable message. Commit messages must follow the pattern:
    [Tests] Add unit tests for <FileName> — <brief description of what is covered>
    

    Example: [Tests] Add unit tests for util.js — capitalize, formatName, calculateGrandTotal, checkUpdateRequired

  • Do not batch multiple test files into a single commit. One file = one commit, once green.
  • Do not commit a file while any tests in it are failing or skipped without explanation.

Action Items

# Task Owner Phase
1 Set up __tests__/ directories and Jest coverage config Engineer Pre-work
2 Write tests for util.js pure functions Engineer 1
3 Write tests for RequestUtility, RequestDeduplicator, DebounceUtility Engineer 2
4 Write tests for formatGamePeriodLabel, bet.js pure functions Engineer 3
5 Write tests for env.js, notificationUtils.js, appDeeplink.js Engineer 4
6 Write tests for custom hooks Engineer 5
7 Validate npm test -- --coverage reaches 80%+ on all files in scope Engineer Post

Open Questions

  • Should coverage thresholds be enforced in CI immediately after Phase 1, or only after all phases are complete?
  • env.js uses module-level IIFE evaluation — confirm that jest.resetModules() per-test is acceptable given test suite performance.
  • Some hooks (useProfile) interact with a Lucra SDK (lucra-plugin) that has a custom mock in client/__mocks__/. Verify the existing mock is sufficient before writing hook tests.

Approvals

  • Trace Carrasco
  • Filip Pacyna/Troy Lenihan