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/, andservices/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; usejest.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/envto controlgetCdnHost/isProductionreturns. - Mock
./request/userto isolatefetchUserNamefrom network. - Stub
window.google.mapsfor geolocation helpers.
Phase 2 — Request Layer Classes (global/request/)
Targets:
client/src/global/request/request.js→__tests__/request.test.jsclient/src/global/request/RequestDeduplicator.js→__tests__/RequestDeduplicator.test.jsclient/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.jsuses module-level IIFE evaluation — confirm thatjest.resetModules()per-test is acceptable given test suite performance.- Some hooks (
useProfile) interact with a Lucra SDK (lucra-plugin) that has a custom mock inclient/__mocks__/. Verify the existing mock is sufficient before writing hook tests.
Approvals
- Trace Carrasco
- Filip Pacyna/Troy Lenihan