Test Strategy
Purpose
Build a test suite that catches real defects, runs fast enough to be run, and does not need to be rewritten every time the code is refactored.
When to Use
- Designing a test suite for a new project.
- A suite that is slow, flaky, or fails to catch bugs.
- Deciding what to test for a specific change.
- Setting a coverage policy without making coverage the goal.
Capabilities
- Test-level selection: unit, integration, contract, end-to-end.
- Test-double strategy: what to fake, what to use for real.
- Coverage interpretation.
- Flakiness diagnosis and elimination.
- Suite performance: parallelization and selective execution.
Inputs
- The system, its dependencies, and its risk profile.
- The current suite: its runtime, its failure rate, its bug-escape rate.
Outputs
- A test suite whose distribution across levels is deliberate.
- A fast feedback loop for the tests run on every change.
- Zero tolerated flakes.
Workflow
- Test behavior, not implementation — A test that breaks when you rename a private method is a test that prevents refactoring rather than enabling it.
- Choose the level by what you are verifying — Business logic: unit tests, fast and many. Integration with a real database or queue: integration tests, fewer. A complete user journey: end-to-end, a handful.
- Use real dependencies where practical — A test against a real Postgres in a container catches the SQL error that a mocked repository never will. Mocks verify that you called a thing; they do not verify that it works.
- Write the test that would have caught the bug — After every production defect. This is where the highest-value tests come from — a real bug is empirical evidence of an untested path.
- Treat a flake as a defect — Quarantine it immediately, then fix it. A suite with a 2% flake rate teaches the team to re-run and eventually to ignore.
- Keep the fast loop fast — Under five minutes for what runs on every change, or people will stop running it.
Best Practices
- Coverage is a signal, not a target. 100% coverage with assertion-free tests catches nothing; 60% coverage on the paths that matter catches most things. Look at what is not covered, not at the number.
- The most valuable tests are at the boundaries: the empty list, the null, the concurrent write, the network failure. The happy path is the one that already works.
- Mock what you do not own (a third-party payment API); use the real thing for what you do (your own database). Mocking your own repository layer tests only that your mock is configured correctly.
- A test that requires a comment to explain what it is testing is testing too much.
- Never assert on a log message or an internal call count unless that is the contract. Those assertions break on every refactor and catch nothing.
- Delete tests that no longer earn their keep. A test suite is code and carries maintenance cost.
Examples
A test that verifies behavior, and one that verifies implementation:
# Bad: verifies the implementation. Breaks on any refactor; catches no bugs.
def test_refund_calls_gateway():
gateway = Mock()
RefundService(gateway).refund(order, 1000)
gateway.refund.assert_called_once_with(order.charge_id, 1000)
# This passes even if the refund is never recorded, the amount is wrong
# in the database, and the customer is charged again.
# Good: verifies the behavior that the user and the business care about.
def test_refund_reduces_balance_and_is_idempotent(db, fake_gateway):
order = place_order(db, total_cents=5_000)
service = RefundService(fake_gateway, db)
result = service.refund(order.id, amount_cents=2_000)
assert result.ok
assert db.orders.get(order.id).refunded_cents == 2_000
assert fake_gateway.total_refunded(order.charge_id) == 2_000
# The same request again must not refund twice.
service.refund(order.id, amount_cents=2_000, idempotency_key=result.key)
assert db.orders.get(order.id).refunded_cents == 2_000
The distribution that actually works:
Unit ~70% milliseconds each business logic, edge cases, error paths
Integration ~25% seconds each real DB, real queue, real HTTP layer
End-to-end ~5% tens of seconds the three journeys that must never break
The proportions matter less than the principle: put the volume where the tests
are fast and the coverage is cheap, and reserve the slow, brittle level for
the handful of paths whose failure would be catastrophic.
Notes
- Testcontainers gives you a real database, queue, or cache per test run, in Docker. It has largely eliminated the argument for mocking your own infrastructure — the real thing is now nearly as easy.
- Contract tests (Pact and similar) verify that two services agree on their interface without deploying both. They are the right tool when end-to-end tests across services have become the bottleneck.
- A test suite that has never caught a bug in production code is either a very good sign or a very bad one. Check which by mutating the code and seeing whether the tests notice.
1---2name: test-strategy3description: Use when deciding what to test and at which level. Covers the test pyramid, what belongs in unit versus integration versus end-to-end tests, coverage as a signal rather than a target, and eliminating flakiness.4---56# Test Strategy78## Purpose910Build a test suite that catches real defects, runs fast enough to be run, and does not need to be rewritten every time the code is refactored.1112## When to Use1314- Designing a test suite for a new project.15- A suite that is slow, flaky, or fails to catch bugs.16- Deciding what to test for a specific change.17- Setting a coverage policy without making coverage the goal.1819## Capabilities2021- Test-level selection: unit, integration, contract, end-to-end.22- Test-double strategy: what to fake, what to use for real.23- Coverage interpretation.24- Flakiness diagnosis and elimination.25- Suite performance: parallelization and selective execution.2627## Inputs2829- The system, its dependencies, and its risk profile.30- The current suite: its runtime, its failure rate, its bug-escape rate.3132## Outputs3334- A test suite whose distribution across levels is deliberate.35- A fast feedback loop for the tests run on every change.36- Zero tolerated flakes.3738## Workflow39401. **Test behavior, not implementation** — A test that breaks when you rename a private method is a test that prevents refactoring rather than enabling it.412. **Choose the level by what you are verifying** — Business logic: unit tests, fast and many. Integration with a real database or queue: integration tests, fewer. A complete user journey: end-to-end, a handful.423. **Use real dependencies where practical** — A test against a real Postgres in a container catches the SQL error that a mocked repository never will. Mocks verify that you called a thing; they do not verify that it works.434. **Write the test that would have caught the bug** — After every production defect. This is where the highest-value tests come from — a real bug is empirical evidence of an untested path.445. **Treat a flake as a defect** — Quarantine it immediately, then fix it. A suite with a 2% flake rate teaches the team to re-run and eventually to ignore.456. **Keep the fast loop fast** — Under five minutes for what runs on every change, or people will stop running it.4647## Best Practices4849- Coverage is a signal, not a target. 100% coverage with assertion-free tests catches nothing; 60% coverage on the paths that matter catches most things. Look at what is *not* covered, not at the number.50- The most valuable tests are at the boundaries: the empty list, the null, the concurrent write, the network failure. The happy path is the one that already works.51- Mock what you do not own (a third-party payment API); use the real thing for what you do (your own database). Mocking your own repository layer tests only that your mock is configured correctly.52- A test that requires a comment to explain what it is testing is testing too much.53- Never assert on a log message or an internal call count unless that *is* the contract. Those assertions break on every refactor and catch nothing.54- Delete tests that no longer earn their keep. A test suite is code and carries maintenance cost.5556## Examples5758**A test that verifies behavior, and one that verifies implementation:**5960```python61# Bad: verifies the implementation. Breaks on any refactor; catches no bugs.62def test_refund_calls_gateway():63 gateway = Mock()64 RefundService(gateway).refund(order, 1000)65 gateway.refund.assert_called_once_with(order.charge_id, 1000)66 # This passes even if the refund is never recorded, the amount is wrong67 # in the database, and the customer is charged again.6869# Good: verifies the behavior that the user and the business care about.70def test_refund_reduces_balance_and_is_idempotent(db, fake_gateway):71 order = place_order(db, total_cents=5_000)72 service = RefundService(fake_gateway, db)7374 result = service.refund(order.id, amount_cents=2_000)7576 assert result.ok77 assert db.orders.get(order.id).refunded_cents == 2_00078 assert fake_gateway.total_refunded(order.charge_id) == 2_0007980 # The same request again must not refund twice.81 service.refund(order.id, amount_cents=2_000, idempotency_key=result.key)82 assert db.orders.get(order.id).refunded_cents == 2_00083```8485**The distribution that actually works:**8687```text88Unit ~70% milliseconds each business logic, edge cases, error paths89Integration ~25% seconds each real DB, real queue, real HTTP layer90End-to-end ~5% tens of seconds the three journeys that must never break9192The proportions matter less than the principle: put the volume where the tests93are fast and the coverage is cheap, and reserve the slow, brittle level for94the handful of paths whose failure would be catastrophic.95```9697## Notes9899- Testcontainers gives you a real database, queue, or cache per test run, in Docker. It has largely eliminated the argument for mocking your own infrastructure — the real thing is now nearly as easy.100- Contract tests (Pact and similar) verify that two services agree on their interface without deploying both. They are the right tool when end-to-end tests across services have become the bottleneck.101- A test suite that has never caught a bug in production code is either a very good sign or a very bad one. Check which by mutating the code and seeing whether the tests notice.