Writing Tests You Will Actually Keep
Most abandoned test suites were not too small. They were slow, unclear or tied so tightly to the code that every change broke them. A few rules keep a suite worth running.
What a test suite is for
Unit testing means checking individual pieces of a program in isolation, and test automation means running those checks by machine instead of by hand. The point is not to prove the code is correct, which no finite set of tests can do. The point is to notice quickly when behaviour you relied on stops working.
That reframing matters, because it tells you which tests are worth keeping: the ones that would have caught a change you did not intend. A test that never fails, and never could, is documentation at best and dead weight at worst.
The shape of a readable test
Almost every useful test has three parts: set up the situation, do the one thing under test, then check the result. Keeping those parts visually separate makes a failing test readable at a glance, which is the moment when readability actually pays.
def test_invoice_rounds_once_at_the_end():
invoice = Invoice(lines=[Line(0.015), Line(0.015)]) # arrange
total = invoice.total() # act
assert total == 0.03 # assert
Two further habits help. Name the test after the behaviour, not the function, so the failure message tells you what broke rather than where. And assert one behaviour per test; a test with six assertions stops at the first failure and hides the rest.
What to test first
On a codebase with no tests, writing them in file order is demoralising and rarely useful. Start where a mistake would be expensive or embarrassing:
- Anything involving money, dates, time zones or units.
- Rules with many branches: permissions, eligibility, discounts, validation.
- Code you are about to refactor, written just before you touch it, so the tests describe current behaviour.
- Any bug you have just fixed, captured as a test that fails on the old code.
That last one is regression testing in its simplest form, and it is the cheapest testing habit there is. The investigation is already done; you only have to write down what you learned.
Keep the fast tests fast
A suite that takes a few seconds gets run on every save. A suite that takes a quarter of an hour gets run when someone remembers, which is to say after the mistake. Speed is not vanity: it decides whether the tests are part of your feedback loop or part of your paperwork.
So keep the bulk of the suite in memory, with no network calls, no real clock and no database. Pass the current time in as an argument instead of reading it inside the function; pass in a connection or a repository object instead of opening one. The slower integration tests still matter, but keep them in a separate group you can run on demand and in continuous integration, rather than letting them slow down the group you run constantly.
Coverage is a map, not a score
Code coverage measures how much of your source was executed while the tests ran. Read as a map it is genuinely useful: it shows which modules nobody is exercising, and uncovered error-handling branches are often where real bugs hide. Treated as a target it stops being useful, because tests that execute a line without asserting anything about it raise the number and catch nothing.
For illustration only, suppose a module reports ninety per cent coverage. That tells you a tenth of its lines never ran during the suite. It tells you nothing at all about whether the other nine tenths behaved correctly.
Why suites get abandoned
Brittleness is usually the cause, and it comes from testing how the code works rather than what it does. A test that asserts a specific sequence of internal calls fails whenever you reorganise the internals, even when the behaviour is identical. After enough of those false alarms, people start skipping the suite, and then deleting it.
Prefer assertions about what callers can observe: return values, raised errors, persisted state. Then a passing suite after a refactor means something, and a failure is worth reading.
last updated 2026-11-14