Skip to content

Testing strategy

The unit and API tiers run in a few seconds, with no containers and no network.

just test

The layout

tests/
  unit/    domain and services. No input or output. Milliseconds.
  api/     the app over ASGI: routes, middleware, error handling.
  fakes.py hand-written test doubles.
  conftest.py shared fixtures.

Beside them sit integration/ (real PostgreSQL, Redis and MinIO started in Docker for the test session), contract/ (generated requests checked against the published API contract), and cassettes/ (recorded outbound HTTP responses, replayed offline).

Unit tests carry the weight

Because the domain layer imports nothing but Pydantic, business rules are tested with no database, no event loop, and no fixtures. That is the practical payoff of the layering, and it gives a useful diagnostic:

If a unit test needs a container, a layer boundary has leaked.

Tests of the api layer call the application directly over ASGI — the interface between a Python web server and an application — so no network socket is opened and no port is bound. They are fast enough to run on every save.

Property-based testing

Some tests use Hypothesis, which generates hundreds of random valid inputs and checks that a rule holds for all of them, instead of testing three examples somebody thought of.

It suits the domain models well, because their rules are stated as properties already. "No sequence of valid operations produces an Order whose total disagrees with its lines" is one short test that covers cases nobody would think to write by hand.

When Hypothesis finds a failure it shrinks it — it searches for the smallest input that still fails, so you get a two-line reproduction rather than the random 40-field object it happened to find first.

Warnings are errors

The pytest configuration sets filterwarnings = ["error"]. Any warning raised during a test run fails that test.

A deprecation warning is a dated notice that something will break. Left as output, it scrolls past for two years and then becomes an outage on a routine upgrade. Turned into a failure, it gets handled on the day it appears.

There is exactly one exception, and it is narrow on purpose: Starlette's test client emits a deprecation warning about its own use of httpx. It is suppressed by its precise message and category, not by a blanket ignore::DeprecationWarning — which would silently hide the next warning instead of surfacing it.

Fakes, not mocks

Test doubles are hand-written classes in tests/fakes.py that implement the same port as the real adapter.

A fake in-memory repository is a few lines and behaves like a repository: save then get returns what you saved. A mock configured to return a value proves only that your code called the method you told it to call. When the real adapter's behaviour changes, the fake is wrong in a way a test can catch, and the mock is wrong in a way nothing can catch.

Mutation testing

Mutation testing introduces small deliberate bugs into the source — > becomes >=, True becomes False — and re-runs the tests. A mutation that survives proves the tests never actually checked that behaviour. It measures whether tests assert, where coverage measures only whether lines executed. It is the stronger of the two signals.

It runs over domain/ and services/ — the two layers where a surviving mutant means a business rule nothing actually checks, not a line of routing or wiring. Run it with just mutants; see Commands for the full set (mutants-changed, mutants-gate).

What is not measured yet

Coverage thresholds are not implemented. Nothing in this repository fails a build for a line that never ran.

Your .env is kept out of the tests

The justfile sets dotenv-load := true, so your own .env enters the environment of every recipe — including just test. Without a guard, a test that claims to check "the defaults, with no environment set" would actually be checking your .env, and would pass only because the shipped .env.example happens to match the defaults.

A session-wide fixture in conftest.py strips every APP_* variable from the environment before any test runs, and restores them afterwards. So tests see the real defaults, and a colleague whose .env differs from yours does not get a confusing unrelated failure.

Note that building settings with no .env file is not sufficient on its own: that only stops the settings loader reading a file, and does nothing about APP_* variables already in the environment by the time pytest starts.

Testing note: caplog does not work

Logging setup clears the root logger's handlers, which also removes the one pytest's logging plugin installs. Any test that builds the application gets nothing in caplog.

Assert on captured standard output instead. See Logging.