The point of a test suite is to let you change the code with confidence. A suite that breaks whenever the code changes shape gives you the opposite: it converts every refactor into a negotiation with the tests.
The tell
Extract a class. Run the suite. If tests fail while behaviour is identical, those tests were coupled to structure.
The usual culprit is mocking everything the class touches:
@Test
void appliesDiscount() {
when(repository.findById(ORDER_ID)).thenReturn(Optional.of(order));
when(pricingClient.rateFor(CUSTOMER)).thenReturn(new Rate(0.9));
when(auditLog.record(any())).thenReturn(null);
service.applyDiscount(ORDER_ID, CUSTOMER);
verify(pricingClient).rateFor(CUSTOMER);
verify(auditLog).record(any());
}Every verify here is a statement about how the method works. Move the audit call into a
decorator and the test fails, though nothing a user could observe has changed.
Assert on the outcome
The same test, written against behaviour:
@Test
void appliesDiscount() {
var order = orders.save(anOrder().withTotal(euros(100)).build());
pricing.setRateFor(CUSTOMER, 0.9);
service.applyDiscount(order.id(), CUSTOMER);
assertThat(orders.byId(order.id()).total()).isEqualTo(euros(90));
}orders and pricing are in-memory fakes, not mocks. The test says what the feature does.
It keeps passing through any rearrangement that preserves that.
Where mocks still earn their place
At the system boundary, and only there. A mock is the right tool when the real thing is slow, non-deterministic, or has side effects you cannot undo — a payment gateway, an SMTP server, a clock. Inside your own domain, a fake is almost always better, because a fake enforces the same contract for every test that uses it.
The rule of thumb I use: if I would be comfortable shipping the fake as a real implementation for a different use case, it is a fake. If it exists only to record calls, it is a mock, and it belongs at the edge.