Behaviour, Not Coverage
Turns “add some tests” into tests that state what the code promises, instead of tests that exist to move a percentage.
When to reach for it
When you are about to ask for tests on code that has none, or when coverage is high and you still do not trust the change enough to ship it on a Friday.
What changes
- Every test is named as a sentence about behaviour, so a red run tells you what broke before you open the file.
- Assertions land on what a caller can observe — a return value, a written row, an emitted event, rendered text — not on which private helper ran.
- Behaviours the code promises but nothing checks are written out as a list, so a gap is a line you can read instead of a number below 100.
- You can restructure the implementation without changing behaviour and the suite stays green, because the tests stopped being a second copy of the code.
- Tests that only walk lines — getters, constructors, generated files — get skipped rather than written, so the suite gets smaller as trust goes up.
Pairs with
- Fixture DietCuts test setup down to the few facts the test actually depends on, so the test says what it means.
- Failing Test FirstMakes the agent prove the bug exists — with a test that fails for the right reason — before it is allowed to fix anything.
- Flake HuntFinds out why a test is intermittent, instead of retrying it until the pipeline turns green.
Behaviour, Not Coverage
Tests exist to describe what the code promises. Coverage is a by-product of doing that well. Work in this order.
1. List the promises before writing a test
For the code in front of you, write the sentences a caller relies on:
- The ordinary case ("splits a paid invoice across two line items")
- The boundaries ("an empty cart totals zero rather than erroring")
- The failures ("an expired token is rejected without a database hit")
- The things that must not happen ("a declined charge writes no order")
If you cannot write four such sentences, you do not understand the code well enough to test it yet. Read it again, or ask.
2. Turn each promise into the test name
The name is the promise, in the words the runner will print on failure:
rejects an expired token— a failure tells you what broketest handleAuth— a failure tells you only where to start reading
3. Assert on what a caller can observe
The return value, the persisted row, the published message, the HTTP response, the text on screen. Not private state, not the order of internal calls, not that a helper was invoked.
The check: could someone rewrite the implementation, keep every promise, and leave this test green? If not, the test is a copy of the code and will go red on every refactor for no reason worth having.
4. Say what you are not testing, and why
Write that list too. "Retry backoff timing is not covered" is a known gap someone can decide about. An uncovered path nobody has named is not.
5. Prove each test can fail
Break the behaviour on purpose — invert a condition, return a constant — and confirm the test goes red. A test that passes against broken code is worse than no test, because it is counted.
Rules
- Do not write a test to move a number. If a line is uncovered and no promise needs it, the honest options are "name the missing promise" or "delete the code", never "add a test that runs it".
- Do not name a test after the function it calls. Name it after the behaviour that would be missed if it vanished.
- Do not assert that a mock was called. That tests your test.
- Do not test the language, the framework or the ORM. They have suites of their own, and yours will only break when they upgrade.
- Do not chase a percentage across generated code, plain accessors or configuration. Exclude them and say in one line that you did.