Skip to content

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:

  1. Decide what you're adding or changing.
  2. Write the test first in apps/<app>/tests.py or a new test_<thing>.py. Run pytest — it should go red.
  3. Write just enough code to go green.
  4. 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 stub mode (CLASSIFIER_MODE=stub) that uses keyword matching on the filename. Tests that exercise the classifier set this in pytest.ini or as a module fixture.
  • Gmail APIapps/emailscan/services.py:run_scan() accepts an injected gmail_service parameter. Tests pass a mock that returns canned message lists.
  • DVLA + DVSAapps/profiles/vehicle_lookup.py has a mock mode (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 — duplicated id="..." 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_id NOT NULL — Document needs a person. Use apps.households.services.get_self_person(household).
  • OneToOne caching — if a test modifies a profile via profile.save() then re-reads named_item.property_profile, Django returns the cached instance. Use named_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 varspytest.ini sets DJANGO_SETTINGS_MODULE. Override env vars per test with monkeypatch.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.