What is worth a test
A test that mocks the thing under test passes while the bug ships. Rules, boundaries, and asserting on behaviour rather than on which methods were called.
Coverage is a number you can raise without learning anything. This lesson is about the other question — which tests are worth having — and it starts with a test that passes against code that is wrong.
Two tests, one bug
A discount rule: over 5000 paise, take 10% off. Here is the implementation, with one wrong character:
public class Discount {
public int apply(int amountPaise) {
if (amountPaise >= 5000) return amountPaise - (amountPaise / 10);
return amountPaise;
}
}>= where the rule says over. Now two tests of the class that uses it:
@Test
void testingTheMock() {
Discount mock = mock(Discount.class);
when(mock.apply(6000)).thenReturn(5400);
Checkout checkout = new Checkout(mock);
assertEquals(5400, checkout.total(6000));
verify(mock).apply(6000);
}
@Test
void theBoundary() {
Checkout checkout = new Checkout(new Discount());
assertEquals(5000, checkout.total(5000), "the rule says OVER 5000");
}Tests run: 2, Failures: 1
CheckoutTest.theBoundary:23 the rule says OVER 5000 ==> expected: <5000> but was: <4500>The first test passes. It stubs the discount to return 5400, then asserts it returned 5400, then verifies it was called. Every line is green and none of it touched the rule. It would keep passing if Discount.apply returned a random number.
The second test fails, and it fails on the one input where the rule is ambiguous in English and decisive in money.
Both count the same towards coverage. One of them is a liability, because it will keep passing while the bug ships.
The question to ask
Not "is this line covered". Ask: if this were wrong, would anything tell me?
That reframes it usefully, because it turns a test from a ritual into a claim. A test earns its place when it would fail for a reason somebody cares about.
Which gives a short list of what is worth testing:
- Business rules. Anything a person could argue about. The discount threshold, the refund window, who may cancel an order.
- Boundaries. Where a rule changes: exactly 5000, exactly 18, the last day of the month, the empty list, one element, the maximum.
- Bugs you have already had. A test written from a real incident is the highest-value test in any suite, because it is proven to be able to fail.
- The contract at your edges. What the API returns, what the database enforces.
- Anything with an
ifwhose branches mean different things to a customer.
And what is usually noise:
- Getters, setters,
toString, generated code. They are correct by construction; a test of them fails only when you change them on purpose. - A framework doing its job. You do not need a test that Spring injects a bean or that Jackson serialises a string.
- Tests that restate the implementation. If the test is the code written twice, it fails whenever the code changes and never when it is wrong — which is worse than nothing, because it costs you every refactor.
Test behaviour, not structure
The single most useful discipline, and the one that decides whether a suite helps or hinders a year later.
// structure: fails when you rename or reorganise, passes when the rule is wrong
verify(repository).findById(42L);
verify(mapper).toDto(any());
// behaviour: fails when the answer is wrong, survives a rewrite
assertEquals(5000, checkout.total(5000));A suite full of the first kind makes refactoring expensive — every internal change breaks tests that were not about that change — and teams respond by refactoring less. That is how a test suite ends up protecting the design it was supposed to let you improve.
verify is not banned. It is right when the interaction is the behaviour: an email must be sent exactly once, a payment must not be retried. Use it for effects the outside world can see, not for the route your code took to get there.
What "unit" means, and the argument about it
The word causes more confusion than it earns. Two readings:
- A unit is a class. Everything it touches is mocked. Tests are fast and numerous, and they break on every refactor because they know the shape of the code.
- A unit is a unit of behaviour — one class or five, whatever implements the rule, with only real boundaries stubbed. Tests are still fast, break far less on refactoring, and are what most people mean by a good unit test today.
The second is the more useful default, and the practical rule that follows is: mock at the boundary, use real objects inside it. A service, its validator and its mapper can all be real; the repository and the HTTP client are mocked.
Coverage is a floor, not a target
Coverage tells you what was executed, not what was checked. A test with no assertions covers everything it runs.
It is genuinely useful in one direction: a class at 0% is a class nobody tested, and that is worth knowing. The direction it fails in is being a target — a team asked for 80% writes tests of getters, because that is the cheapest way to raise the number.
Mutation testing answers the question coverage cannot. It changes your code — flips a > to >=, replaces a return with a constant — and asks whether any test noticed. A surviving mutant is a line that runs without being checked. pitest is the usual tool in Java, it is slow, and running it once on the module that carries your business rules is an education.