Testing¶
pytest + pytest-django + model-bakery. ~376 tests, runs in ~75s.
Running tests¶
# Everything
python3 -m pytest
# One app
python3 -m pytest apps/documents
# One file
python3 -m pytest apps/documents/test_bulk.py
# One test
python3 -m pytest apps/documents/test_bulk.py::test_bulk_delete
# Stop at first failure
python3 -m pytest -x
# Verbose
python3 -m pytest -v
# Watch mode (install pytest-looponfail separately)
python3 -m pytest --looponfail
CI runs pytest -q on every PR. The job fails if any test fails or if pytest itself errors (e.g. a syntax error in a new test file).
Red/green TDD¶
The project's been built test-first. Recommended discipline:
- Decide what you're adding or changing.
- Write the test first in
apps/<app>/tests.pyor a newtest_<thing>.py. Run pytest — it should go red. - Write just enough code to go green.
- Refactor if needed; tests stay green.
The saved memory note for this rule: show red before green; never batch test + code into one edit pass. The reason matters — when both land in one commit you lose evidence the test was actually exercising the new code.
Stubbing external APIs¶
Three external dependencies need stubbing in tests:
- Anthropic Claude — the classifier has a
stubmode (CLASSIFIER_MODE=stub) that uses keyword matching on the filename. Tests that exercise the classifier set this inpytest.inior as a module fixture. - Gmail API —
apps/emailscan/services.py:run_scan()accepts an injectedgmail_serviceparameter. Tests pass a mock that returns canned message lists. - DVLA + DVSA —
apps/profiles/vehicle_lookup.pyhas amockmode (VEHICLE_LOOKUP_MODE=mock) returning deterministic data keyed off the plate hash.
If you're adding a new external dependency, the convention is: wrap it in a service function with a mock/live mode toggled by an env var, default mock for tests.
Static template checks¶
Two CI tests prevent specific recurring template bugs:
apps/common/tests/test_template_comments.py— multi-line{# ... #}Django comments leak as visible HTML; the test fails if any template has a{#followed by a newline before the closing#}. Use{% comment %} ... {% endcomment %}for anything spanning more than one line.apps/common/tests/test_template_id_drift.py— duplicatedid="..."across templates with different class lists (because OOB swaps will silently overwrite the rendered element with whichever copy renders last).
Add similar checks for any future bug-shape that bites us twice.
Multi-tenant isolation tests¶
There's a contract test pattern: every domain test should also assert that cross-household reads return empty / 404. Search for test_*_other_household to see the pattern. New apps should add equivalent tests.
Common gotchas¶
person_idNOT NULL — Document needs aperson. Useapps.households.services.get_self_person(household).- OneToOne caching — if a test modifies a profile via
profile.save()then re-readsnamed_item.property_profile, Django returns the cached instance. Usenamed_item.refresh_from_db()to clear the cache. - transaction=True — needed when a test exercises code that spawns a daemon thread (e.g.
run_scan_in_background). - Settings env vars —
pytest.inisetsDJANGO_SETTINGS_MODULE. Override env vars per test withmonkeypatch.setenv("CLASSIFIER_MODE", "live")or via@override_settings.
Smoke testing prod¶
The test suite runs against SQLite locally. After a merge to main, the GitHub Actions deploy lands on Fly in ~2 min. Then:
fly auth whoami # must be olly.willans@gmail.com
fly logs -a lifefile # tail
curl -sf https://lifefile.fly.dev/api/health
Smoke clicks: dashboard, upload a doc, connect Gmail, accept a triage finding. If anything looks off, fly ssh console -a lifefile and run management commands directly.
Wiping prod state¶
Useful after pipeline changes:
fly ssh console -a lifefile -C 'python manage.py wipe_findings --user <username> --dry-run'
fly ssh console -a lifefile -C 'python manage.py wipe_findings --user <username>'
# Add --include-connection to also force a re-OAuth.
fly ssh console -a lifefile -C 'python manage.py wipe_findings --user <username> --include-connection'
ACCEPTED findings link to confirmed Document rows; the documents themselves are not deleted by this command — only the triage rows.