Tests are usually sold as bug-catching. That undersells them. A codebase without tests is one you are afraid to change — every refactor is a gamble, so the shortcuts accumulate and the design ossifies.
What a test suite actually buys is permission to change the code. Everything below follows from that: a test worth keeping is one that fails when behaviour breaks and stays quiet when you merely rearrange the implementation.
The pyramid, and what each layer buys
Diagram, as text
- End-to-end: few, slow, critical paths only leads to Integration: boundaries, real database
- Integration: boundaries, real database leads to Unit: many, fast, behaviour not implementation
Three layers, differing in scope, speed and what they can prove.
| Layer | Scope | Speed | Typical count | Catches |
|---|---|---|---|---|
| Unit | One function or class, dependencies faked | Milliseconds | Thousands | Logic errors, edge cases |
| Integration | Several real components together | Seconds | Hundreds | Wiring, queries, contracts |
| End-to-end | The whole system through the UI | Minutes | Dozens | Broken critical journeys |
The shape is a pyramid because cost and flakiness rise faster than confidence as you climb. A unit test that fails names the broken function. An end-to-end test that fails tells you something, somewhere, in a chain of eight components, is wrong — or that a button rendered 200ms late.
The common failure is the inverted pyramid: a handful of unit tests under a mass of slow browser tests. The suite takes 40 minutes, fails randomly twice a week, and the team learns to re-run rather than investigate. A suite people ignore is worse than no suite, because it costs money and provides false comfort.
Unit tests: behaviour, not implementation
The unit test that survives refactoring asserts what the code does, not how.
def test_withdrawing_more_than_the_balance_is_rejected():
account = Account(balance=100) # arrange
with pytest.raises(InsufficientFunds): # act + assert
account.withdraw(150)
assert account.balance == 100 # state unchangedThree things make this useful:
The name states the rule. When it fails in CI six months from now, the failure line alone tells you what broke. test_withdraw_2 tells you nothing.
Arrange, act, assert. Set up state, perform one action, check the outcome. When a test has three acts, it has three reasons to fail and the failure no longer localises the bug.
It asserts the observable outcome. It checks the balance and the exception — not that _validate_amount was called once with 150. Assert on internal calls and every rename breaks a test that was never about correctness.
Cover the edges deliberately, because that is where the bugs are: zero, one, empty, null, negative, the maximum, the boundary itself and just past it. Off-by-one errors live at exactly these values and nowhere else.
Test doubles: fake the boundary, not the internals
Unit tests need to isolate the code under test from slow or unpredictable dependencies — clocks, networks, payment gateways.
Stubs return canned answers so the test can proceed. Mocks additionally assert that a specific call was made. Prefer stubs. A mock asserting charge() was called with exact arguments is a copy of the implementation, and it will fail on refactors that changed nothing a user can see.
Fake at architectural seams — the payment client, the clock, the mailer. Faking the internals of the class you are testing means you are testing your fakes.
Integration tests: where the real bugs are
Integration tests exercise components together against real infrastructure — a real database, a real HTTP layer:
def test_creating_a_user_persists_them(client, db):
response = client.post("/users", json={"name": "Alice", "email": "a@example.com"})
assert response.status_code == 201
assert db.query("SELECT email FROM users WHERE id = %s", response.json()["id"]) == "a@example.com"This catches an entire class of bug unit tests cannot: a wrong column name, a migration never applied, a serialiser dropping a field, a transaction that never commits. Every one of those passes a fully mocked unit suite.
Run them against the same engine you deploy on. SQLite standing in for Postgres is fast and will happily accept syntax and type behaviour that production rejects. Disposable containers make a real database cheap enough that the substitution is no longer worth making.
Each test must set up and tear down its own data. Tests that depend on rows another test created pass alone, fail in parallel, and fail differently depending on order.
End-to-end tests: few, and only for money paths
An end-to-end test drives the real system as a user does. It is the only kind that proves the whole thing is wired together, and the most expensive to own.
test("a customer can complete checkout", async ({ page }) => {
await page.goto("/cart");
await page.getByRole("button", { name: "Checkout" }).click();
await page.getByLabel("Card number").fill("4242424242424242");
await page.getByRole("button", { name: "Pay" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
});Restrict them to journeys that cost you money when broken: sign-up, login, checkout, the core action of your product. Anchor selectors to roles and labels rather than CSS classes, and wait for conditions rather than sleeping for a fixed duration — timing-based waits are the single largest source of flaky tests.
Treat a flaky end-to-end test as a broken test. Fix it or delete it; a suite that fails randomly trains people to ignore red.
Coverage measures execution, not correctness
Coverage tells you which lines ran during the suite. That is genuinely useful for finding untested code, and it is not a measure of quality:
def test_divide():
divide(10, 2) # 100% coverage of divide, asserts nothingBecause the number is easy to game and easy to mandate, targets distort behaviour. Around 70–80% is a reasonable expectation for most codebases; chasing 100% means writing tests for trivial getters and error branches that cannot occur, which adds maintenance cost and no confidence. Look at what is uncovered, not at the percentage — an untested payment path matters, an untested __repr__ does not.
Writing the test first
Test-driven development inverts the order: red, green, refactor. Write a failing test for the behaviour you want, write the smallest code that passes, then improve the design with the test holding you steady.
The benefit most people expect is coverage. The real benefit is design pressure: code that is hard to test is usually badly coupled, and writing the test first makes you feel that before you have built on top of it. Watching the test fail first matters too — a test that never failed may not be asserting anything.
TDD suits well-specified logic with clear inputs and outputs. It fits exploratory work and UI layout much less well, and treating it as a rule for all code is how it gets abandoned.
Check your understanding
Common mistakes
Testing the implementation. Asserting on private methods and internal call sequences produces tests that break on every refactor and catch no real bugs.
Shared mutable state between tests. Tests that pass in sequence and fail in parallel almost always share a database row, a singleton or a file.
Conditionals in tests. An if in a test means it asserts different things on different runs. Write two tests.
Only testing the happy path. The bugs live in the error branches, so those are the branches worth asserting on.
Ignoring a flaky test instead of fixing it. Re-running until green teaches the team that red means nothing, and the day it means something you will re-run anyway.
Treating coverage as the goal. A number satisfied by assertion-free tests measures effort, not safety.
Practice
Your end-to-end suite takes 40 minutes and fails randomly about one run in five. The team has started re-running it until it passes. Diagnose the real problem and give a plan.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Consider what a suite that fails randomly actually communicates to the people reading it.
Show a strong answer
The real problem is not the 40 minutes — it is that the suite no longer carries information. A test that fails one run in five for no reason has trained the team to treat every failure as noise, which means a genuine regression will also be re-run until it passes. The suite is now worse than having none, because it costs 40 minutes and provides false assurance.
The plan, in order:
- Stop the bleeding: quarantine the flaky tests. Move them out of the blocking suite immediately. This is uncomfortable and it is right — a suite that is red half the time blocks nothing anyway, and quarantine makes the remaining green meaningful today.
- Fix or delete each quarantined test. Most flakiness has a small number of causes: waiting on a fixed sleep instead of a condition, shared state between tests, dependence on execution order, real time, real network. Each is fixable. A test nobody will fix should be deleted — an unmaintained test is a liability, not an asset.
- Push coverage down the pyramid. Most of what the 40 minutes verifies does not need a browser and a full environment. Move business rules into fast unit tests and boundary behaviour into integration tests, and keep end-to-end for a small number of critical paths — checkout, login, payment.
- Parallelise what remains, once it is deterministic. Doing this first would only multiply the flakiness.
The target to state aloud: end-to-end tests should be few enough to name, and the suite should be green unless something is genuinely broken. Reliability of the signal matters more than breadth of the coverage.
You are asked to test a function that charges a card through a third-party SDK. Describe what you fake, what you do not, and why.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Fake at the boundary you own, not inside the thing you are testing.
Show a strong answer
Fake the third-party SDK. It is outside your process and your control: calling it for real makes tests slow, flaky, dependent on network and credentials, and — for a payment provider — potentially expensive. Replace it at the boundary with a double that returns success, decline, timeout and malformed responses on demand.
Do not fake your own internals. The function's own logic — deciding the amount, applying a discount, mapping the provider's response to your domain — is the thing under test. Faking any of it means testing the mock.
Do not fake the domain objects you pass in. Build real ones. A fake Order that returns whatever the test wants can express states a real order could never reach, so the test passes for a scenario that cannot occur while missing one that can.
What to assert: the outcome and the observable effects, not the interactions. Asserting "charge was called once with these arguments" couples the test to how the function talks to the SDK, so a refactor that batches or retries breaks it despite identical behaviour. Assert that a successful call produces a paid order, that a decline produces an unpaid order with a reason, and that a timeout leaves the order in a state that reconciliation can resolve.
The cases that actually matter are not the happy path: decline, timeout, a provider returning success for a request you thought failed, and the same request arriving twice. Those are where the money is lost, and they are only reachable with a fake — which is the strongest argument for having one.
Separately, keep a small number of contract tests against the real provider's sandbox, run outside the main suite. A fake encodes your belief about the provider's behaviour, and beliefs drift.
Key takeaways
- Tests buy permission to change the code; catching bugs is a side effect of that.
- Keep the pyramid shape — many unit tests, fewer integration, a handful of end-to-end — because cost and flakiness rise faster than confidence as you climb.
- Assert observable behaviour, not implementation, or every refactor breaks tests that were never about correctness.
- One behaviour per test, arranged as arrange–act–assert, with a name that states the rule it protects.
- Prefer stubs to mocks, and remember mocks only verify your code against your assumptions — integration tests verify the assumptions.
- Run integration tests against the same database engine you deploy on, with per-test setup and teardown.
- Coverage measures execution, not correctness. Look at what is uncovered rather than at the percentage.
- A flaky test is a broken test; tolerating red trains the team to ignore it.
Further reading
- Kent Beck, Test-Driven Development: By Example (Addison-Wesley, 2002).
- Martin Fowler, "Mocks Aren't Stubs" (2007) and "TestPyramid" (2012).
- Mike Cohn, Succeeding with Agile (Addison-Wesley, 2009) — where the pyramid was first drawn.
- John Micco, "Flaky Tests at Google and How We Mitigate Them" (Google Testing Blog, 2016).
- Gerard Meszaros, xUnit Test Patterns (Addison-Wesley, 2007) — the vocabulary of doubles: dummy, stub, spy, mock, fake.