Skip to content

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) clears MAX_UPLOAD_BYTES, CORS_ORIGINS, and the DEEPGRAM_* vars so a developer's backend/.env cannot change results.
  • png_bytes(width, height) builds an image in memory.
  • tiny_video generates a one-second clip with ffmpeg at run time, so no binary fixture lives in the repo.
  • requires_ffmpeg skips 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:

  1. Install the two tools (once per machine):
brew install uv ffmpeg

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.

  1. Install the locked dependencies:
cd backend
uv sync

uv run syncs automatically, so this step only keeps installer noise out of the first test output.

  1. Run the whole suite:
uv run pytest

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.

  1. 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
  1. Check coverage the way CI will:
uv run pytest --cov=app --cov-fail-under=95 --cov-report=term-missing
  1. Lint and format before committing:
uv run ruff check .
uv run ruff format .

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:

  • units runs the pure logic on Node, where it behaves exactly as it does in the Worker and starts in milliseconds.
  • routes runs 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 with SELF.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.ts applies everything in migrations/ 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.ts takes 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.
  • env reaches 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 *-logic or 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 TestClient test 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: coverageThreshold in apps/mobile/jest.config.js, with a higher bar on src/lib/, src/theme/, src/hooks/, src/features/voice/, and src/features/media/ than on screens. CI runs pnpm test:mobile --ci --coverage.
  • Worker API: thresholds in apps/api/vitest.config.ts, over all of src/. The provider is istanbul, not v8: v8 coverage needs node: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 of src/ except main.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 means Platform.OS is 'ios', so the Android arms of vibration-patterns-screen.tsx (where Android-only haptics become enabled) and resolveApiBaseUrl are only reachable by mocking Platform. src/hooks/use-color-scheme.web.ts loads only under a web preset and is excluded from coverage.
  • tests/video-quiz-screen.test.tsx uses raw react-test-renderer rather 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 FormData in the test environment coerces React Native's file object to a string, so tests/api-transcribe.test.ts spies on FormData.prototype.append to see what a device would actually send.
  • cueAt in src/features/video-quiz/captions.ts is tested but unused: the screen concatenates all cue text instead of syncing to playback time.
  • expo-image is pinned at 57.0.1 (and excluded from expo install version 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 ?? '' after c.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:

npx knip          # unused files, dependencies, and exports across the monorepo

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.