name: testing-backend-services description: Designs and writes tests for backend services — unit, integration with real dependencies in containers, contract tests, and minimal E2E — with deterministic, parallel-safe practices. Use when the user asks to test an API or service, write unit/integration/contract tests, fix flaky backend tests, set up testcontainers, or decide what to mock. Do not use for frontend or UI testing, or for load and performance testing.
Testing Backend Services
When to use / when NOT to use
- Use for: test strategy and test code for services and APIs: unit, integration, contract, E2E; de-flaking; test data design.
- Do NOT use for: browser/UI testing, or load/perf/soak testing — different tooling and goals.
Core rules
- Shape tests as a pyramid. Many milliseconds-fast unit tests on pure logic; a solid layer of integration tests; one happy-path E2E per critical flow. Inverting it (E2E-heavy) buys slow, flaky suites.
- Integration tests hit real dependencies in containers. Spin up Postgres/Redis/broker via testcontainers.
- ✅ test repository code against a real Postgres container
- ❌ mock the database driver and assert SQL strings — that tests your mock.
- Mock only what you don't own (third-party HTTP APIs, clocks, randomness) — and pin those mocks with contract tests where possible.
- Test behavior, not implementation. Assert on outputs, state changes, and emitted events — not on which internal methods were called. Refactors must not break green tests.
- No sleeps. Poll with a timeout for async effects; freeze the clock for time logic; seed randomness. A test that needs
sleep(2)is a race you scheduled.- ✅
wait_until(lambda: outbox.count() == 1, timeout=5) - ❌
time.sleep(2); assert outbox.count() == 1
- ✅
- Each test is independent and parallel-safe: owns its data (unique IDs per test), never depends on execution order, cleans up via transaction rollback or per-test schema.
- Factories over shared fixtures.
make_user(email=...)with overridable defaults beats a giantfixtures.sqlthat every test secretly depends on. - Contract tests guard API boundaries: provider verifies it still satisfies consumer expectations (Pact or OpenAPI-based) on every CI run — cheaper than E2E across repos.
Workflow
- Classify the change: pure logic → unit; touches DB/broker/HTTP edge → integration; crosses service boundary → contract; business-critical flow → one E2E.
- Write the test first at the lowest level that can catch the bug.
- Build test data with factories; give every entity a per-test unique key.
- Replace any sleep/order dependency with polling, frozen clocks, seeded RNG.
- Validate: run the suite twice — full run and
--last-failedin random order (pytest -p randomly); both must pass. Run the new test 20× (pytest --count=20 -x) to prove it's not flaky.
Edge cases & failure modes
- Test passes locally, fails in CI → almost always shared state or timing; check for fixed ports, shared DB rows, real clock usage.
- Container startup dominates runtime → reuse one container per session with per-test transactions/schemas, not one container per test.
- Untestable code (network calls in constructors, global singletons) → refactor for injection first; don't monkey-patch around design problems.
- Non-determinism sources (UUIDs, now(), env) → inject them; asserting on wall-clock values is a flake factory.
References
Deeper recipes and gotchas: see references/patterns.md