Test Strategy (Backend)
Purpose
Test the backend where bugs actually live — integration against a real database, contract tests at service boundaries — not a wall of mocked unit tests that pass while production breaks.
Universal — the backend testing pyramid, real-DB integration testing, and consumer-driven contract testing are principles; the runner/container/Pact-binding differs by language.
Procedure
Unit tests — pure logic only
- Pure functions, domain rules, calculations — fast, no I/O
- Don't unit-test glue code by mocking everything; that tests the mocks, not the system
Integration tests — against a REAL database (the bulk for backends)
- Use Testcontainers to spin up a real Postgres per test run — not an in-memory fake or mocked repository
- Tests exercise actual SQL, constraints, RLS, transactions — where backend bugs hide
- Mocking the DB hides constraint/migration/query bugs that only appear against real Postgres
Contract testing (Pact) — at service boundaries (the GAP)
- Consumer-driven: the consumer writes a test expressing what it needs → generates a contract → the provider verifies it independently
- Only consumer-used behavior is tested, so providers can safely change anything no consumer depends on
- Kills "deploy the world to check compatibility" — each side verifies independently in its own pipeline
E2E — critical journeys only
- A few high-value end-to-end flows (signup → core action → result)
- Expensive and flaky; keep the count low
Set coverage thresholds by criticality
- Branch coverage ≥ target (default 70%); ≥ 90% on money/auth/data-integrity paths
- Coverage is a floor, not a goal — high coverage with implementation-coupled assertions gives false confidence; chase untested behavior
5b. Assert behavior, not implementation
- Test what the system does (returns this DTO, persists this row, emits this event), not how it does it internally (which function got called)
- Tests that break on refactor — when behavior didn't change — are testing the wrong thing
5c. Keep tests deterministic (flake kills suites)
- No real time: control
Date.now()/ clocks via fake timers; neversleep()to "wait for" something - Real DB per test, clean state: Testcontainers spin-up + truncate/transaction-rollback between tests; isolation > leftovers
- Control randomness (seeded
Math.random/crypto), wallclock, and the network (mock outbound or use Testcontainers for downstream too) - Tests pass in isolation AND in any order — no shared mutable state
- Validate (validation loop)
- Run the suite; if branch coverage < target on critical logic → add tests and re-run until met
- For contract tests: change the provider in a way no consumer uses → verify contracts still pass (proves the contract captures only real dependencies)
Anti-patterns
| ❌ Anti-pattern | ✅ Correct |
|---|---|
| Mocking the database in tests | Real Postgres via Testcontainers |
| All unit tests, no integration | Integration-heavy for backends (bugs live in I/O) |
| Deploy-the-world to check service compat | Consumer-driven contract tests (Pact) |
| Many flaky E2E tests | Few critical-journey E2E; integration for the rest |
| Uniform coverage target | Higher bar on money/auth/data-integrity paths |
| Asserting "function X was called with args Y" | Assert observable behavior (return value, DB row, emitted event) |
Real time / sleep(n) / random clocks |
Fake timers + seeded random + controlled DB state |
| Tests passing alone but failing in suite | No shared mutable state; pass in any order |
Severity tiers
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | 0% coverage on payment/auth/data-integrity logic; no integration test against real DB; services with no contract tests breaking each other | Fix immediately |
| Major | DB mocked in integration tests; business logic < 50% branch coverage | Fix this sprint |
| Minor | Missing contract test on a low-risk internal boundary; flaky E2E | Schedule within 2 sprints |
Completion Criteria
- Pure logic unit-tested
- Integration tests run against real Postgres (Testcontainers)
- Contract tests on service boundaries (Pact)
- Branch coverage ≥ target (≥90% critical paths)
- All gated in CI; no skipped tests in main
Output
- Test suites: unit / integration (Testcontainers) / contract (Pact) / few E2E
- Coverage report + CI gate
- Commit format:
test(<scope>): integration tests for <feature>/test(contract): Pact for <consumer>↔<provider>
Implementation
TypeScript + NestJS + Postgres (default)
- Runner: Vitest or Jest
- Integration: Testcontainers (
@testcontainers/postgresql) → real Postgres; run migrations; test against it - Contract: Pact (
@pact-foundation/pact) — consumer tests generate pacts; provider verification in its pipeline - Coverage gate in CI (
--coveragewith threshold)
Other stacks
- Python / FastAPI: pytest +
testcontainers-python;pact-pythonfor contracts - Go: stdlib
testing+testcontainers-go;pact-go - Universal: Testcontainers (Docker-based) and Pact (polyglot) are cross-language; the pyramid + real-DB principle is universal
Related skills
schema-design— integration tests run against the real schemaapi-contract— contract tests verify the API contract holdscicd-pipeline— tests + contract verification gate the pipeline
Reference
- Key insight encoded: Only consumer-used behavior is tested by a contract, so providers can safely change anything no consumer depends on — kills the need to deploy-the-world to validate compatibility. Integration tests run against a real DB, not mocks.