Skip to content

Writing a test

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 euros
an order below the threshold gets no discount
a rejected payment leaves the basket intact
concurrent withdrawals cannot overdraw the account

Read 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.

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 threshold
val customer = aCustomer().gold().build()
val order = anOrder().totalling(120.euros).build()
// when the discount is applied
val priced = pricing.apply(customer, order)
// then 15% comes off
priced.total shouldBe 102.euros

One 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.

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 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.

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.

Six questions. They take a minute and they catch most of what matters.

  1. Does the name say what should happen, without naming a class or an exception type?
  2. Could the implementation be rewritten, same behaviour, and this still passes?
  3. Can it fail for more than one reason?
  4. Will the failure message be enough to diagnose it?
  5. Is everything in the arrange block load-bearing?
  6. Is this the cheapest level that can check it honestly — and is something else already checking it?