Writing a test
The name is the specification
Section titled “The name is the specification”A test name is read in a failure report, out of context, by someone who did not write it. It has to say what should happen and under what condition — not what the code does.
Bad, and all of these are common:
testCalculateDiscount()testDiscount2()shouldWork()testUpdateUserWithNullEmailThrowsIllegalArgumentException()The first three say nothing. The fourth is worse than it looks: it names the implementation’s exception type, so it will be renamed when that changes, and it still does not say why a null email is invalid.
Good:
gold members get 15% off orders over 100 eurosan order below the threshold gets no discounta rejected payment leaves the basket intactconcurrent withdrawals cannot overdraw the accountRead the list of names in a suite. It should be a readable specification of the behaviour. If it reads as a list of method names, the tests are bound to structure.
Three parts, visibly
Section titled “Three parts, visibly”Arrange, act, assert. Given, when, then. The words differ; the structure does not, and it should be visible at a glance — with blank lines if the language has nothing better.
// a gold member with an order over the thresholdval customer = aCustomer().gold().build()val order = anOrder().totalling(120.euros).build()
// when the discount is appliedval priced = pricing.apply(customer, order)
// then 15% comes offpriced.total shouldBe 102.eurosOne act per test. Two actions means the test is describing a scenario, and when it fails you do not know which action was wrong. Scenarios belong in end-to-end tests, where the journey is the point.
Arrange only what matters. Everything in the arrange block should be load-bearing. A test that sets up seven fields to assert on one teaches the reader that all seven are relevant. Builders with defaults exist for this — name only what the test depends on. See test data.
Assertions
Section titled “Assertions”Assert one thing. Not one assert statement — one concept. Three assertions that together establish “the order was priced correctly” are one thing. Three assertions about pricing, persistence and notification are three tests.
Assert the value, not the shape. total shouldBe 102.euros, not
total shouldNotBe null. A null check passes for every wrong answer that is not
null.
Never assert on a mock unless the call is the behaviour. verify(repo).save(x)
asserts that the implementation calls a method — it will pass when the save is
never committed and fail when someone renames the method. Assert the outcome
instead. The exception is when the call is the observable behaviour: publishing
an event, sending an email. See unit.
Use the domain vocabulary. 102.euros rather than 10200. If the test
speaks in the language of the domain, so should the code — and if it cannot, that
is a design finding.
The failure message
Section titled “The failure message”The most under-invested line in most suites, and the one read under the most pressure.
expected true, got false requires opening the test, reading the code, and
reconstructing what it meant. expected total to be 102.00 EUR but was 120.00 EUR for a gold customer with a 120.00 EUR order is a diagnosis.
Modern assertion libraries produce good messages if you let them — assert on the
value rather than on a boolean you computed yourself. assertTrue(total == 102)
throws away everything the library could have told you.
What not to write
Section titled “What not to write”Tests for framework guarantees. That the ORM persists a field, that the router routes, that the serialiser serialises. These fail loudly the first time anything else runs.
Tests for generated code, at the level it was generated from. An OpenAPI client is covered by the contract.
Tests that restate the implementation. If the assertion is a copy of the production expression, it will agree with any bug.
A second test for a property already covered at a lower level. The rule against paying twice, from the strategy.
Review checklist
Section titled “Review checklist”Six questions. They take a minute and they catch most of what matters.
- Does the name say what should happen, without naming a class or an exception type?
- Could the implementation be rewritten, same behaviour, and this still passes?
- Can it fail for more than one reason?
- Will the failure message be enough to diagnose it?
- Is everything in the arrange block load-bearing?
- Is this the cheapest level that can check it honestly — and is something else already checking it?