AnyLearn
All lessons
Programmingintermediate

What Is Worth Testing, and the Shape That Follows

Every test costs time to write, time to run, and time to maintain when the code moves. This lesson works out what that budget should buy: what each level of test can and cannot catch, why the pyramid has the shape it does, why some teams invert it, and the arithmetic that decides both questions before anyone argues about it.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

A test is a purchase, not a virtue

Testing discussions go wrong when tests are treated as morally good, so that more is better. Tests are a purchase. You spend writing time, run time on every commit forever, and maintenance time whenever the code they touch moves. What you buy is confidence that a specific kind of breakage will be caught before a user meets it.

Once it is a purchase, the useful questions become concrete:

  • Which breakages would actually hurt, and how likely are they?
  • What does catching this one cost, at each level I could catch it?
  • What will this test cost me every time I refactor?

Key idea: the goal is not coverage of the code. It is coverage of the risk. A payment path with no tests and a settings toggle with five is a portfolio nobody chose deliberately, and it is the default outcome of writing tests wherever they are easiest to write.

Everything else in this course is machinery for spending that budget well.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. A test is a purchase, not a virtue

Testing discussions go wrong when tests are treated as morally good, so that more is better. Tests are a purchase. You spend writing time, run time on every commit forever, and maintenance time whenever the code they touch moves. What you buy is confidence that a specific kind of breakage will be caught before a user meets it.

Once it is a purchase, the useful questions become concrete:

  • Which breakages would actually hurt, and how likely are they?
  • What does catching this one cost, at each level I could catch it?
  • What will this test cost me every time I refactor?

Key idea: the goal is not coverage of the code. It is coverage of the risk. A payment path with no tests and a settings toggle with five is a portfolio nobody chose deliberately, and it is the default outcome of writing tests wherever they are easiest to write.

Everything else in this course is machinery for spending that budget well.

2. The three levels, and what each one cannot see

Tests are usually grouped by how much of the system they run. The labels vary between teams, but the trade never does.

LevelRunsCatchesStructurally blind to
UnitOne function or class, dependencies replacedLogic errors, edge cases, branch mistakesAnything about how the pieces connect
IntegrationSeveral real components together, often a real databaseWiring, queries, serialisation, contract driftThe browser, the network, the deploy
End to endThe whole system as a user meets itEverything, in principleNothing, in principle, at brutal cost

The last column is the one that decides architecture. A unit test cannot tell you the two services disagree about a date format, because it never let them talk. An end-to-end test can catch that and everything else, which sounds like a winning argument until you count what it costs to run, to keep stable, and to debug when it fails.

Those costs are not opinions. They are arithmetic, and the arithmetic has a shape.

3. The arithmetic that produces the shape

Put representative durations on the levels and multiply. A unit test that exercises one function in memory runs in about a millisecond. An integration test that touches a real database runs in about 100 milliseconds. A browser-driven end-to-end test runs in about 10 seconds.

Now price three suites that each contain 2,000 tests, differing only in composition.

Suite runtime for 2,000 tests, by composition
minutes0501001503.234.6138.790/9/150/40/1020/40/40
Source: computed: 1 ms per unit test, 100 ms per integration test, 10 s per end-to-end test, 2,000 tests split by the stated percentages

The pyramid-shaped suite finishes inside a coffee break. The inverted one takes over two hours, which means it cannot run on every commit, which means it stops being a safety net and becomes a nightly report somebody reads on Tuesdays.

Key idea: the pyramid is not an aesthetic preference. It is what falls out of wanting a suite that runs on every change, given that the levels differ in cost by orders of magnitude.

4. Two shapes, one trade-off

The test pyramid was introduced by Mike Cohn in Succeeding with Agile in 2009: many fast isolated tests at the base, fewer integration tests above, a thin cap of end-to-end tests.

In 2018 Kent C. Dodds proposed the Testing Trophy for front-end work, which deliberately fattens the integration band. The argument is not that Cohn was wrong about cost; it is that in a modern UI, isolated unit tests of components verify implementation details that break on every refactor while missing the failures users actually hit, so the confidence-per-cost ratio at the integration level is better than the pyramid assumes.

Read the two shapes as answers to one question with different inputs. When your units contain real logic, the pyramid's base earns its size. When your units are thin wiring around a framework, testing them in isolation buys little, and the money is better spent one level up.

The generalisable rule: put the weight where your bugs actually live, which is a question about your codebase, not about a diagram.

flowchart TD
A["How much real logic is in one unit?"] --> B["A lot: algorithms, rules, state machines"]
A --> C["Little: wiring around a framework"]
B --> D["Pyramid: wide fast unit base"]
C --> E["Trophy: weight on integration"]
D --> F["Same rule: put weight where bugs live"]
E --> F

5. What a good test asserts

Level is only half the design. The other half is what the test actually pins down, and this is where most maintenance pain is created.

A test should assert on observable behaviour: what the caller receives, what the system does that someone can see. It should not assert on how the result was reached. The distinction is concrete:

# Brittle: asserts the mechanism
def test_discount_calls_the_rate_table():
    cart = Cart(items=[Item(price=100)])
    with patch("pricing.rate_table.lookup") as lookup:
        lookup.return_value = 0.1
        cart.total()
        lookup.assert_called_once_with("standard")

# Durable: asserts the behaviour
def test_standard_customer_gets_ten_percent_off():
    cart = Cart(items=[Item(price=100)], tier="standard")
    assert cart.total() == 90

Both pass today. Replace the rate table with a pricing service and the first one fails while the software is perfectly correct, which teaches the team that failing tests are noise. That lesson, once learned, is expensive to unlearn.

Gotcha: a test that fails when you refactor without changing behaviour is not protecting you. It is charging you rent on your own design.

6. Where to actually spend the budget

Given finite time, the highest-value targets are predictable across almost every codebase:

  • Logic with branches and edge cases. Pricing, permissions, date handling, retry policy, anything with the word "unless" in its specification. Cheap to test, dense with mistakes.
  • Anything that has already broken. A regression test written the day a bug is fixed is the best-evidenced test you will ever write: you have proof that failure mode is reachable.
  • Contracts between components. The serialisation format, the API shape, the database schema assumption. These break silently and at a distance.
  • The paths where failure is expensive. Payment, auth, data deletion, anything irreversible. Test these even when they feel obvious.

And the low-value end, where suites accumulate weight without confidence: getters and setters, code that only calls a framework and returns, generated code, and third-party libraries, which are the vendor's job to test and yours to characterise only where you depend on surprising behaviour.

The test that earns its keep tells you something you could not have predicted by reading the code. If you can see the answer at a glance, so can the next reader, and the test is buying very little.

7. The question that reorders a backlog

One exercise settles most disagreements about what to test next, and it takes about a minute per candidate.

Predict first

You have time to write exactly one test for a checkout service. Candidate A: the cart total function, pure arithmetic, 40 lines, no tests. Candidate B: the end-to-end "user completes a purchase" flow, which touches 6 services and is currently untested. Which do you write?

8. The structure that makes tests readable

A test is read far more often than it is written, usually by someone who did not write it, at the worst possible moment: it is red and they do not know why. Two conventions carry most of the readability.

Arrange, act, assert. Set up the world, perform exactly one action, check the outcome. When a test has three actions and seven assertions, a failure tells you the region broke, not the fact.

Name the behaviour, not the function. test_total tells a reader nothing when it fails at 3am. test_expired_coupon_is_rejected_at_checkout names the claim being disproved, which is often the entire debugging session.

In practice: the failure message is the product. A test that fails with assert False has wasted the moment it was built for; one that fails with expected 90, got 100 for tier=standard has already told you where to look. Most modern frameworks give you this for free from a plain comparison, which is one more reason to assert on values rather than on mechanism.

That is the design layer. The next lesson takes the piece that makes unit tests possible at all, and shows how it becomes the most common way for a suite to go wrong: replacing real dependencies with stand-ins.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why is the test pyramid's shape usually described as economic rather than aesthetic?
    • Because unit tests find more bugs per test than other levels
    • Because the levels differ in cost by orders of magnitude, so a suite that runs on every commit must be mostly cheap tests
    • Because end-to-end tests cannot catch logic errors
    • Because integration tests are harder to write correctly
  2. What is a unit test structurally unable to catch?
    • An off-by-one error in a loop
    • An unhandled edge case in date arithmetic
    • A mismatch in how two components interpret a shared format, since it never lets them interact
    • A wrong branch in a permissions rule
  3. What is the core argument behind Kent C. Dodds' Testing Trophy relative to Cohn's pyramid?
    • That unit tests are always a waste of time
    • That end-to-end tests have become cheap enough to dominate a suite
    • That test count matters more than test level
    • That when units are thin wiring around a framework, isolated tests pin implementation details and miss real failures, so integration buys more confidence per unit cost
  4. A test patches a collaborator and asserts it was called with specific arguments. What is the main risk?
    • It runs more slowly than an equivalent behavioural test
    • It fails during a behaviour-preserving refactor, teaching the team that red tests are noise
    • It cannot be run in continuous integration
    • It will silently pass even when the collaborator is missing
  5. Which candidate test has the strongest evidence that its failure mode is actually reachable?
    • A regression test written the day a specific bug was fixed
    • A test for a getter that returns a stored field
    • A test asserting a third-party library behaves as documented
    • A test added to raise a module's coverage percentage

Related lessons

Programming
intermediate

Test Doubles: Isolation and What It Costs

Replacing a real dependency with a stand-in is what makes a unit test fast, deterministic, and able to reach states you cannot otherwise produce. It is also how a suite ends up green while the system is broken. This lesson covers the five kinds of double, when each is right, and the two failure modes that follow every team who reaches for mocks by reflex.

8 steps·~12 min
Programming
intermediate

Benchmarks That Hold Up, and Knowing When to Stop

A benchmark is an experiment, and most are badly designed enough to produce confident wrong answers. This lesson covers what a measurement must control to mean anything, the ways microbenchmarks lie including code the compiler deletes, how to catch regressions in continuous integration despite noisy machines, and how to recognise the point where optimising stops paying.

7 steps·~11 min
Programming
intermediate

Flakiness, Coverage, and Suites That Survive

A test suite is a product with a maintenance cost, and two forces decide whether it stays useful: flakiness, which destroys the signal, and the metrics teams use to judge it, which mostly measure the wrong thing. This lesson covers where flakiness comes from, the arithmetic that makes it fatal at scale, what coverage research actually found, and what to measure instead.

8 steps·~12 min
Programming
intermediate

Property-Based Testing: Assert the Law, Not the Example

An example-based test checks the cases you thought of, which is exactly the set that excludes your bugs. Property-based testing inverts it: state a law the code must obey for all inputs, let the machine hunt for a counterexample, and let it shrink that counterexample to something you can read. This lesson covers the property patterns, generators, shrinking, and where the technique stops fitting.

7 steps·~11 min