Testing¶
How this repo tests itself, and how to add a case without inventing a pattern.
Commands¶
# Everything a pull request is judged on, in one pass (from the repo root)
pnpm verify
# Mobile
pnpm test:mobile # full suite
pnpm test:mobile --coverage # with the thresholds CI enforces
pnpm --filter senior-health-app test tests/api.test.ts # one file
pnpm --filter senior-health-app test -t "releases the microphone" # one case
# Worker API (from apps/api/)
pnpm test:api # both projects: units and routes
npx vitest # watch mode
npx vitest run --project routes # just the HTTP tests, in workerd
npx vitest run --coverage # with the thresholds CI enforces
# Web app (from apps/web/)
pnpm --filter @senior-health/web test
npx vitest # watch mode
# Backend (from backend/)
uv run pytest
uv run pytest tests/test_uploads.py
uv run pytest --cov=app --cov-report=term-missing
Husky runs lint, the workspace-wide typecheck, the mobile suite, and the Worker
suite on every commit, plus ruff and pytest when a backend/ file is staged. CI
runs the same checks plus the coverage gates, and expo-doctor on top.
The layers¶
Work at the lowest layer that can express the behavior. A rule that can be a pure-function test should not be a screen test.
| Layer | Where | What it proves |
|---|---|---|
| Pure logic | tests/*-logic.test.ts, tests/stories-scoring.test.ts, tests/voice-*.test.ts |
Rules, boundaries, and error paths, with no React and no clock |
| Content integrity | tests/content-integrity.test.ts, tests/media-catalog.test.ts |
Authored data is internally consistent: correct answers exist, ids are unique, media is described |
| Design invariants | tests/theme-contrast.test.ts, tests/pressable.test.tsx, tests/one-screen-fit.test.ts |
The accessibility guarantees the app is built on, enforced rather than documented |
| Hooks | tests/use-*.test.tsx via renderHook |
State machines, subscriptions, and cleanup |
| Screens | tests/*-screen.test.tsx via render |
What a senior can actually reach, queried through the accessibility layer |
| Worker units | apps/api/tests/*.test.ts via Vitest |
Auth primitives, access rules, question building, and the story generator |
| Worker HTTP | apps/api/tests/routes/*.test.ts via SELF.fetch in workerd |
Status codes, permissions, and what each endpoint stores, against real D1 and R2 |
| Web app | apps/web/tests/*.test.{ts,tsx} via Testing Library in jsdom |
The API client's requests, sign-in and sessions, and the media, quiz, and story-builder flows |
| Backend HTTP | backend/tests/test_*.py via TestClient |
Status codes, validation, and response shapes |
| Backend units | backend/tests/test_video_service.py, test_uploads.py |
Error branches that would otherwise need a real binary or a real network |
Rules that keep the suite stable¶
Query by role and name. Screen tests use getByRole('button', { name })
rather than test ids, so they break when the accessibility layer breaks. That
is the point. Use testID only for something deliberately hidden from
assistive technology (the music quiz cover art, the Stories photo), and pass
{ includeHiddenElements: true } when querying it.
render and renderHook are async in React Native Testing Library 14, as
are unmount and rerender. Always await them.
Inject, do not mock, the sources of nondeterminism. pickSessionQuestions,
pickPlayOrder, buildAnswerRecord, and buildVideoQuizResponse all take a
random, now, or isoTimestamp parameter for exactly this reason. Reach for
tests/helpers/time.ts (seededRandom, cyclingRandom, freezeNow) rather
than patching globals.
The timezone is pinned to UTC in jest.config.js, before Jest starts its
workers. Anything formatting a time (formatTime, buildAnswerRecord, the
haptics status line) is therefore stable on any machine. Always pass an
explicit locale to formatTime in a test.
Nothing scrolls, so screens are measured. tests/one-screen-fit.test.ts
holds every authored string to its character budget and every screen's worst
case to the display window (see the one-screen rule), then
replays every screen against every supported device at that device's scale. A
new screen adds its stack there; a new kind of content adds its role. Tests
that query a long list step through it with tests/helpers/paging.ts.
Tests render on an iPhone SE. Layout scales with the window, and React
Native's default mock window is 750x1334 pixels, which would read as an
iPad. tests/helpers/display.ts pins the reference display for every file;
call setDisplay(deviceNamed('iPad Pro 13"')) before rendering to test
another one.
Fake timers also fake the clock. jest.useFakeTimers() moves Date.now()
forward with jest.advanceTimersByTime, which is what drives the voice poll
loop's sample timestamps. RNTL binds the real setImmediate at import, so its
async flushing still works under fake timers.
The helpers¶
apps/mobile/tests/helpers/ — a folder inside tests/ is safe because Jest
only collects *.test.*.
| Helper | Use it when |
|---|---|
factories.ts |
You need a valid QuizQuestion, VideoQuizItem, SongPreview, Reminder, or VoiceChoice and only care about one field |
audio.ts |
A test drives the voice pipeline. scriptedRecorder({ meterings, uri }) plays back a metering series; audio.recorder exposes the jest.fn handles |
video.ts |
You need to assert play/pause/replay on the video player |
net.ts |
Anything touching src/lib/api.ts. stubFetch records calls; setHostUri and setApiUrlEnv drive base-URL resolution |
time.ts |
Randomness or a clock is involved |
navigation.ts |
A screen reacts to being left behind. blurScreen() runs the focus-effect cleanups, as Back or Home does; focusScreen() puts them back |
app-state.ts |
A screen reacts to the app leaving the foreground. mockAppState() gives you emit('background') and setCurrentState; call restore() after |
paging.ts |
A row you are looking for is on a later page of a PagedList. pageTo presses "Later" until it appears; countAcrossPages counts the whole list |
display.ts |
A test cares which device it is on. Everything renders on the reference display (iPhone SE) unless setDisplay(deviceNamed('iPad Pro 13"')) says otherwise |
tests/setup.ts installs the expo-audio, expo-video, expo-constants,
expo-router, safe-area, and AsyncStorage (official in-memory) mocks for every
file, and resets the audio, video, navigation, and storage state before each
test. You rarely need a
local jest.mock. To rig storage failures or slow loads, use *Once mock
variants on the AsyncStorage methods (see tests/settings-storage.test.ts);
they fall back to the working in-memory behavior on the next call, where a
plain mockImplementation would poison later tests.
The Worker has two, one per kind of test.
apps/api/tests/helpers/d1.ts is for the unit tests. fakeD1(answer)
implements the slice of D1 the Worker uses (prepare(sql).bind(...).first())
and answers queries by looking at the SQL, so a test reads like the table it
describes. It also records every query, which is how bankAccessFor proves it
never reaches bank_members for a bank's owner.
apps/api/tests/routes/helpers.ts is for the HTTP tests, where the database
is real. seedAccounts() writes the same cast every time: a senior who owns a
bank, a Loved One with manage access, a guest with view access, and an
outsider who owns a bank the others cannot see, which is what proves the
"a bank you cannot reach answers 404, exactly like one that does not exist"
rule. Passwords go through the Worker's own hashPassword, so the login route
meets a row it would really encounter. call(method, path, { token, body, form })
signs a request and json(response) reads it back.
The web app has apps/web/tests/helpers.ts. stubApi(routes) answers fetch by
whole path (not by substring, so /banks cannot swallow
/banks/:id/question-sets), and anything unlisted gets a body with every
collection empty, which keeps a test about the shell from having to describe
sections it is not about. apps/web/tests/setup.ts installs an in-memory
localStorage: Node 26 ships a disabled one that shadows jsdom's, and the app
keeps its session token there.
Backend fixtures live in backend/tests/conftest.py:
clean_env(autouse) clearsMAX_UPLOAD_BYTES,CORS_ORIGINS, and theDEEPGRAM_*vars so a developer'sbackend/.envcannot change results.png_bytes(width, height)builds an image in memory.tiny_videogenerates a one-second clip with ffmpeg at run time, so no binary fixture lives in the repo.requires_ffmpegskips a test when the binaries are absent.
Async service functions are driven with asyncio.run(...); there is no
pytest-asyncio, and none is needed.
Starting to test the backend, step by step¶
The backend suite needs no configuration, no .env, and no network. From a
fresh clone to a green run:
- Install the two tools (once per machine):
uv manages Python and the virtualenv; ffmpeg generates the tiny_video
fixture at run time. Without ffmpeg the video tests skip via
requires_ffmpeg rather than failing.
- Install the locked dependencies:
uv run syncs automatically, so this step only keeps installer noise out
of the first test output.
- Run the whole suite:
101 tests, well under a second. No DEEPGRAM_API_KEY is needed: the
autouse clean_env fixture clears the environment, and the transcribe
tests stub the network.
- Narrow the run while working on something:
uv run pytest tests/test_images.py # one file
uv run pytest -k "thumbnail" # every test whose name matches
uv run pytest -x # stop at the first failure
- Check coverage the way CI will:
- Lint and format before committing:
The pre-commit hook runs these plus pytest whenever a backend/ file is
staged; CI runs the same with the coverage gate on top.
To test by hand against a live server, start it with pnpm dev:backend from
the repo root (or uv run uvicorn app.main:app --reload from backend/),
then curl http://localhost:8000/health, or open the Swagger UI at
http://localhost:8000/docs and exercise the upload endpoints with real
files. If something else is holding port 8000 (a stray mkdocs serve is the
usual suspect), pass --port 8100 to uvicorn, or BACKEND_PORT=8100 on the
Docker path, and point the app at it with EXPO_PUBLIC_API_URL.
How the Worker route tests run¶
apps/api/vitest.config.ts defines two projects, because the Worker has two
kinds of test:
unitsruns the pure logic on Node, where it behaves exactly as it does in the Worker and starts in milliseconds.routesruns the real Worker inside workerd, through@cloudflare/vitest-pool-workers, with the same D1 and R2 bindings production uses. Tests call the deployed entry point withSELF.fetch(...), so routing, CORS, and the auth middleware are all in the path.
Three things make that comfortable to write:
- The schema is the real one.
tests/routes/apply-migrations.tsapplies everything inmigrations/to each test file's database before its tests run. Add a migration and these tests pick it up with no edit. - Each file gets its own database and bucket, so nothing leaks between
files.
tests/routes/failures.test.tstakes advantage of that: it drops a table on purpose to prove an unexpected failure answers a plain 500 rather than the database's error text. envreaches the same bindings the Worker has. A test can assert that an upload really landed in R2, or that a delete really removed it, instead of trusting the status code.
In vitest-pool-workers 0.19 (the Vitest 4 line) the pool is configured as a
plugin, cloudflareTest({ ... }), rather than the older
defineWorkersProject/poolOptions.workers shape that most examples online
still show.
Adding a case¶
- A rule or edge case → add to the matching
*-logicor service test. No rendering. - New authored content (a question, a song, a video, a photo) → the integrity tests already cover it. If it fails, the content is wrong.
- A new screen behavior → a screen test that presses what a senior would press and asserts what a screen reader would hear.
- A new FastAPI endpoint → a
TestClienttest for the happy path and each status code, plus unit tests for branches needing a binary or the network. - A new Worker endpoint → a file in
apps/api/tests/routes/: the happy path, each error status, and the permission trio (manage succeeds, view is refused with 403, a bank the caller cannot reach answers 404). Put the rule behind it in a unit test rather than proving it again over HTTP. - A bug → write the failing test first, then fix it. All three defects
fixed alongside this suite (the microphone left open on unmount, the
double-start race in
useVoiceAnswer, and the 500 on an undecodable image) were verified to fail without the fix.
The coverage gate¶
CI enforces thresholds; the pre-commit hook does not, so committing stays fast and a developer without ffmpeg is not blocked by skipped video tests.
- Mobile:
coverageThresholdinapps/mobile/jest.config.js, with a higher bar onsrc/lib/,src/theme/,src/hooks/,src/features/voice/, andsrc/features/media/than on screens. CI runspnpm test:mobile --ci --coverage. - Worker API: thresholds in
apps/api/vitest.config.ts, over all ofsrc/. The provider is istanbul, not v8: v8 coverage needsnode:inspector, which does not exist inside workerd, so it cannot measure the route tests at all. - Web app: thresholds in
apps/web/vitest.config.ts, over all ofsrc/exceptmain.tsx(which only mounts the app). - Backend:
uv run pytest --cov=app --cov-fail-under=95. The only uncovered lines are inside_deepgram_request, the one function that actually opens a socket.
Thresholds are set a few points below what the suite achieves. Raise them when coverage climbs; do not lower them to make a PR pass.
Known gaps¶
- The suite runs as iOS.
jest-expo's root preset meansPlatform.OSis'ios', so the Android arms ofvibration-patterns-screen.tsx(where Android-only haptics become enabled) andresolveApiBaseUrlare only reachable by mockingPlatform.src/hooks/use-color-scheme.web.tsloads only under a web preset and is excluded from coverage. tests/video-quiz-screen.test.tsxuses rawreact-test-rendererrather than RNTL, for a sandbox binding quirk noted in the file. Its assertions do not go through the accessibility query layer the other screen tests use.- The web
FormDatain the test environment coerces React Native's file object to a string, sotests/api-transcribe.test.tsspies onFormData.prototype.appendto see what a device would actually send. cueAtinsrc/features/video-quiz/captions.tsis tested but unused: the screen concatenates all cue text instead of syncing to playback time.expo-imageis pinned at 57.0.1 (and excluded fromexpo installversion checks): 57.0.2 wires an expo-observe integration that jest-expo 57.0.3 cannot mock, so every suite importing a screen with an image fails to load. Unpin when a jest-expo release understands it.- The Worker's remaining uncovered branches are unreachable defaults, like
the
?? ''afterc.req.param('bankId')on a route that cannot match without that parameter. They are left alone rather than contorting a test to reach them.
Occasional sweeps¶
Not every check earns a place in verify. These are worth running by hand now
and then:
Read knip with judgment rather than treating it as a gate. In an Expo repo it
reports babel.config.js and *.web.ts platform files as unused (Metro
resolves both without an import), and it lists exported prop interfaces as
unused types. The findings worth acting on are undeclared dependencies and
genuinely dead modules.