Testing¶
Four layers, each catching a different class of bug — none of them a substitute
for the others. The backend suite proves the logic is right; nothing in it ever
starts Postgres, writes a real object, or drives the app the way a phone does.
The full-stack smoke test proves the opposite: that the pieces boot and talk to
each other for real, without re-testing every edge case pytest already owns.
Mobile's suite covers the client the same way — pure logic in ts-jest, real
component trees in jest-expo — and stops exactly where a real device begins.
flowchart TD
subgraph unit["Logic — fast, every PR and every push to main"]
PT["pytest<br/>backend/tests/"]
JT["Jest (ts-jest)<br/>mobile/src/**/__tests__"]
SC["Jest (jest-expo)<br/>mobile/src/__tests__/screens"]
end
subgraph real["Real infra — its own job"]
SM["Full-stack smoke test<br/>scripts/smoke_test.py"]
NA["native.yml<br/>compile + API-level lint<br/>(native paths only)"]
end
subgraph manual["Real device / real load — manual or scheduled"]
LT["Load test<br/>scripts/load_test_render.py"]
MA["Maestro E2E<br/>.maestro/"]
AT["androidTest<br/>on-device instrumentation"]
KT["Kotlin JVM tests<br/>android/src/test"]
PC["Nightly push check<br/>scripts/push_check.py"]
end
PT -.->|"needs it running for real"| SM
JT -.-> SC
NA -.->|"same Kotlin module"| AT
NA -.-> KT
SM -.->|"same render pipeline,<br/>at scale"| LT
style unit fill:#1f6f43,color:#fff
style real fill:#8a6d1f,color:#fff
style manual fill:#5a3d99,color:#fff
What CI runs, and when¶
ci.yml runs on every pull request and on every push to main — not on a push
to any other branch. The rest only run when what they check could have changed,
or on a clock:
| Workflow | Runs | What it does |
|---|---|---|
ci.yml → backend |
PRs, pushes to main |
ruff check app tests ../scripts, then pytest --cov (with real FFmpeg installed, for test_render.py) |
ci.yml → mobile |
PRs, pushes to main |
npx expo-doctor, tsc --noEmit, npm run lint, npm run test:coverage, npm run check:contract |
ci.yml → smoke |
PRs, pushes to main |
The full-stack smoke test against docker compose |
ci.yml → audit |
PRs, pushes to main |
npm audit (full report, fails only on critical) and pip-audit on requirements.lock (fails on any finding) |
native.yml |
PRs and pushes to main that touch mobile/modules/**, the app config or package.json; by hand |
Compile and lint the Kotlin module; the instrumented tests only by hand — see Native |
docs.yml |
PRs and pushes to main that touch docs/ or mkdocs.yml |
mkdocs build --strict — a broken internal link fails it — and, from main, publishes the site |
e2e-mobile.yml |
By hand only | The Maestro flows on an emulator |
push-check.yml |
06:00 UTC, and by hand | The nightly push check |
backup-restore-drill.yml |
04:00 UTC, and by hand | scripts/restore_drill.py against a freshly seeded docker compose stack: dump, restore into a throwaway Postgres, check row counts, Alembic revision, foreign keys and media |
The contract check regenerates src/api/generated-types.ts from the backend's
OpenAPI schema and fails if it differs from what is committed — the app's
src/api and the FastAPI routers change in separate PRs, and nothing else
notices when they drift apart.
The dependency audit is not advisory. npm audit only stops the build on a
critical advisory, because every high one at the time was transitive through
expo's own build tooling and nobody's to bump — a check that can never go
green teaches people to scroll past red. pip-audit has no severity threshold
to set, so any finding it reports fails the job; the one exception is
--ignore-vuln PYSEC-2026-1325, a timing attack in ecdsa (pulled in by
python-jose) with no upstream fix, ignored deliberately with the reason next
to it in ci.yml. A new unfixable advisory gets the same treatment, not a
looser check.
Backend (backend/tests/)¶
pytest --cov, run on every PR and every push to main, after ruff check.
Every test here talks to SQLite and
every *_backend setting (storage, push, email) is forced to a no-op — see the
_no_backend_talks_to_the_outside_world fixture in conftest.py. That is
deliberate: it makes the suite fast and hermetic, and it is exactly why it
cannot be the whole story (see Full-stack smoke test
below).
One real exception: test_render.py shells out to actual FFmpeg rather than
mocking it, because the render pipeline's failures are all in what FFmpeg
actually does with the bytes — a pixel format ffmpeg silently renegotiates, a
timebase two encodes disagree on. A mock would never catch either. This is also
where the >2-clip cases live that caught the transition-assembly memory bug —
see Known failure modes and safety nets.
Running it locally needs Python 3.11 or newer (requires-python in
pyproject.toml; CI uses 3.12) and ffmpeg and ffprobe on PATH. Without
either binary, test_render.py skips as a whole module — the run still goes
green, with every render test reported as s, so a green local run without
FFmpeg says nothing about the render pipeline.
test_docs_stay_true.py is the documentation's own check: every setting in
Settings has to appear in .env.example (or be excluded on purpose), the
example's values have to agree with the code's defaults, and every relative
link in docs/*.md has to point at a file that exists. It only covers facts
with one unambiguous source; the rest of this page is checked by people.
Mobile (mobile/src/**/__tests__)¶
npm test, two Jest projects (mobile/jest.config.js):
unit(ts-jest, Node environment) — pure logic: the EDL, the write queue, storage, the API client.test:coverage's threshold is not measured over all of it but over an explicit list of 18 pure modules (collectCoverageFrominjest.config.js) — the EDL and engine choice, the write queue, storage,api/retry.tsandapi/multipart.ts, formatting and language.src/api/client.tshas tests (below) but is not in that list, and neither are screens: see the comment at the top ofjest.config.jsfor why a coverage figure over them would be a facade, not a signal.screens(jest-expo) — real component trees via@testing-library/react-native, undersrc/__tests__/screens/rather thanapp/(a*.test.tsxthere would become a bogus route —expo-routerturns every file underapp/into one). This is where the reachability regression tests live: assertions that a screen's critical content sits inside aScrollView/FlatListwith a real scroll viewport, not just present somewhere in the tree.jest-expodoes no real layout, so these check structure, not pixels — a screen can still clip content in a way only a real device shows. Added after eight screens shipped with content stuck outside any scroll container; see theScrollViewentry in gotchas.md.
Both projects map expo-localization to mobile/jest/expo-localization.js,
a phone set to Spanish, so existing assertions keep describing what a Spanish
phone shows. English is covered where the words change order
(src/ui/__tests__/format.en.test.ts, which mocks the module to English) and
by src/i18n/__tests__/language.test.ts, which pins which phone gets which
language and that neither dictionary has a blank entry.
Network-failure behaviour of ApiClient and the upload retry path
(stageWithRetry) is pinned down in src/api/__tests__/client.test.ts and
src/edit/__tests__/serverRender.test.ts: a dropped connection, a request that
times out (ApiClient.request() carries its own 30 s deadline via
AbortController), and a transient 5xx during upload getting the same backoff
a dropped connection does. A 4xx, and the specific "server render disabled"
501, are still treated as final — retrying either would not change the
server's mind.
Accessibility lint — eslint-plugin-react-native-a11y's rules are wired
into mobile/eslint.config.js, catching missing labels, invalid roles, and
nested touchables. They run at warn, not error: the first pass across
existing screens surfaced dozens of pre-existing findings, too many to fix as
part of adding the lint. npm run lint runs as its own step in ci.yml's
mobile job, alongside expo-doctor, tsc, the test suite and the contract
check — so it fails
the build on a real lint error (the one that existed, app.config.js using
__dirname without a Node-aware ESLint environment, is fixed), but the
warn-level a11y findings stay visible-but-non-blocking on purpose. Deciding
whether those should block on new violations (rather than the pre-existing
backlog) is still unstarted work.
Full-stack smoke test¶
scripts/smoke_test.py, wired into ci.yml as its own smoke job — on every
PR and every push to main, no secrets required (every setting in docker-compose.yml has a
workable default). It drives one complete user journey over plain HTTP against
a real stack: real Postgres, real (local) storage, a real built Docker image —
register → login → create a circle → a turn is drawn → recording/rendering
heartbeats → upload → premiere → the audience downloads and byte-matches the
vlog → a push round-trip. One happy path, no variants: every edge case here
already has dedicated pytest coverage, and duplicating them against a slower,
real-infra harness would only make CI slower without adding a signal.
It deliberately never shells out to FFmpeg — the app's own architecture is "the
edit happens on the client," so the server-visible trace of a render is just
the turn's status walking recording → rendering. Real FFmpeg is exercised
elsewhere (test_render.py, and the load test below).
Also runnable by hand against any live deployment (docs/deployment.md →
"Checking it") as a pre/post-deploy check.
Load testing the render pipeline¶
scripts/load_test_render.py fires N concurrent render-fallback requests at
a running backend and reports latency, failures, and (optionally, via
--docker-container) the API container's memory footprint while they run. It
exists because render-fallback is a synchronous endpoint that ties up one of
Starlette's threadpool workers for the whole encode, and FFmpeg is exactly as
heavy as limits.md says: one shared-cpu-1x
machine, and "the memory is for FFmpeg, not the API."
Not wired into CI — it needs SERVER_SIDE_RENDER_ENABLED=true and a
machine sized like production to mean anything; see docs/load-testing.md for
how to run it that way.
Run against what was then the production spec (docker update --cpus=1
--memory=1g --memory-swap=1g, matching Fly's shared-cpu-1x, 1 GB; production
is now 512 MB, so re-run it with --memory=512m --memory-swap=512m to see the
current machine), this is what turned up two real production bugs and settled
the SLO question the issue originally asked. A single render already sits close
to the memory ceiling even after the transition-assembly
fix: two concurrent
renders, of any size, reliably OOM-killed both. The answer wasn't a number to
tune — it was that this machine cannot safely run more than one real assembly at
a time, so render_fallback now enforces exactly that (RenderGate in
app/services/render_gate.py, settings.max_concurrent_renders, default 1):
a second request queues instead of racing the first for memory, and gives up
with a 503 rather than blocking forever if the queue is too long
(settings.render_queue_timeout_seconds). Verified with this script at N=2/3/5
concurrent: all now succeed, latencies stacking in multiples of one render's
time — queued, not failing. Raising the limit above 1 is only a real option if
the machine's memory grows to match; nothing here assumes that will happen.
Native (Android)¶
Three layers, of which CI runs one on its own.
native.yml'scompilejob runs:vlog-editor:compileDebugKotlinand:vlog-editor:lintDebugon every PR, and every push tomain, that touches native paths (mobile/modules/**, the app config,package.json), and on demand. Editing a screen does not pay for a Gradle run. The API-level lint (NewApi— a call above the declaredminSdkthat compiles fine and crashes only on an older phone) blocks the job; it used to becontinue-on-error: true(#105); see theminSdkentry in gotchas.md for the incident that made this matter.- JVM unit tests in
mobile/modules/vlog-editor/android/src/test/—StallGuardTest,DecoderDrainTest,EngineCapabilitiesTestandVideoPipelineSampleSizeTest. They cover the parts of the engine that were written against small interfaces precisely so they could run without a device: the stall deadline, the shared decoder feed/drain loop, the capability check's decision over a made-up codec list, and how far a large photo is downsampled before it becomes a texture. No workflow runs them yet; adding them tonative.yml's compile job is #169. Until then, run them by hand (JDK 17 and an Android SDK, the same as a local build): - On-device
androidTestinstrumentation (#81, extended in #134) — 14 tests acrossTranscoderInstrumentedTest,VideoPipelineInstrumentedTestandAudioPipelineInstrumentedTest, with real fixture clips (rotations, mixed resolutions and frame rates, a silence gap, a truncated file). It is what actually exercisesMediaCodec/OpenGL behaviour, which neither the compile job nor the JVM tests can.native.ymlhas aninstrumented-testsjob for it —workflow_dispatchonly,connectedDebugAndroidTeston an API 34google_apis/x86_64emulator, with the report uploaded as an artifact — kept off push/PR for the same reason as the Maestro job: an emulator boot is slow and the runner's KVM is not guaranteed fast. Three of the tests failed the first time anyone ran the suite (#135); #137 fixed them — swapped rotation fixtures and a real gap in howVideoPipelinenoticed a truncated source — and all 14 pass on themaestro_e2eemulator. That run was local: theinstrumented-testsjob has not been dispatched in GitHub Actions yet, and its header comment still describes the three failures as expected (being corrected in #173).
Maestro E2E¶
.maestro/ holds five YAML flows (register/login, create-circle-and-invite,
turn-draw smoke, record-edit-publish, react-and-comment) and a
workflow_dispatch-only, non-blocking CI job
(.github/workflows/e2e-mobile.yml) that builds the app and drives them
against a real emulator.
All five flows have been run for real against a live emulator and pass,
first locally and since then in GitHub Actions —
a maestro_e2e AVD (Pixel 5, API 34, google_apis/x86_64), a release APK
with the native vlog-editor module built in, against a real docker compose
backend. Getting there found and fixed two real bugs: PUBLIC_BASE_URL
pointing at localhost broke publishing from the emulator (it has to be the
emulator's host alias, 10.0.2.2, for an uploaded file to actually be
reachable), and a racy "stop recording" tap that could double a take. There's
no in-app action to force a draw on demand, so 03–05 seed that state
directly against the API first (scripts/seed_maestro_turn.sh) rather than
waiting on the real scheduler. The CI workflow (e2e-mobile.yml,
workflow_dispatch-only, not wired into push/PR — an emulator boot plus a
real Gradle build is 15–20+ minutes) mirrors this local run as closely as a
CI emulator allows, and has gone green with all five flows: runs 36131208013
(2026-09-25) and 36574008095 (2026-09-29). It is still manual, and still not
a merge blocker. See .maestro/README.md for the full account, including every
selector quirk and timing assumption — that file is the source of truth here,
not this page, since it changes as the flows are actually run again. The flows
select Spanish labels, so the app they run against has to be built with
EXPO_PUBLIC_LANGUAGE=es; see "The Maestro flows select buttons by their
Spanish label" in gotchas.md.
Nightly push check¶
scripts/push_check.py walks the one part of the system that cannot be
verified by reading code: push is fire-and-forget, so a broken setup and a
working one look identical until nobody hears about their turn.
.github/workflows/push-check.yml gives it a clock (06:00 UTC,
workflow_dispatch too) instead of a human remembering to run it.
Active. PUSH_CHECK_EMAIL/PUSH_CHECK_PASSWORD repo secrets point at the
project owner's own account — the only one, of the handful in the production
database, with a device token on file (the app has been opened on a real
device with notification permission granted). A manual workflow_dispatch run
confirmed the whole chain for real: Expo accepted the send and the
notification landed on the phone.
What to run, and when¶
| Change | Run this |
|---|---|
| Backend logic | pytest (fast, and CI runs it on every PR already) |
| Anything touching storage, migrations, or the deploy shape | scripts/smoke_test.py against docker compose up |
| The render pipeline specifically | test_render.py, and scripts/load_test_render.py if the change could affect memory or latency under concurrency |
| A mobile screen | npm test, and add a screens project test if it renders a scrollable area |
| The API client / retry logic | npm test — see client.test.ts for the existing failure-mode coverage to extend |
| The native Kotlin module | Let native.yml compile and lint it; run the JVM tests by hand (testDebugUnitTest, until #169), and androidTest for behaviour changes |
| Anything user-facing across the whole app | Maestro (.maestro/) — see .maestro/README.md for how to run it locally |