Software engineering · testing · design · maintainability
Testing behavior, not implementation
Tests that describe observable behavior survive refactoring; tests that mirror the implementation quietly make every change more expensive.
A test suite earns its keep when it lets you change the code with confidence. A suite that fails every time you refactor, even though nothing a user could notice has changed, does the opposite: it teaches the team to distrust red builds and to avoid improving the design.
The usual cause is not too many tests, but tests that are coupled to how the code works instead of what it promises.
Ask what a caller can observe
Before writing a test, finish this sentence: “When a caller does X, they can see Y.” The answer is the behavior worth pinning down. Return values, persisted state, messages sent to other systems, and error responses all qualify. The order of private method calls, the number of times a helper ran, and the shape of an internal data structure do not.
// Coupled to implementation: breaks if the discount logic is reorganized.
verify(priceCalculator).applyDiscount(order);
verify(taxCalculator).addTax(order);
// Coupled to behavior: survives any internal restructuring.
assertEquals(Money.of("108.00"), checkout.totalFor(order));
Be careful with mocks
Mocks are useful at real boundaries: a payment gateway, a message broker, a clock. Mocking your own collaborators inside the same module records the current wiring and little else. When you notice a test that is mostly when(...) and verify(...), consider using the real objects, or a small in-memory fake, and asserting on the outcome.
Prefer fewer, wider tests over many narrow ones
Test through a public entry point, such as a service method or an HTTP handler, and let the internals vary freely. Reserve tiny unit tests for logic that is genuinely intricate, like parsing, calculations, and state machines. Wider tests are slower to write the first time, but they are cheaper to maintain because they only change when the behavior does.
Treat a painful test as design feedback
If a test needs a dozen collaborators to be constructed, the unit probably has too many responsibilities. If a behavior cannot be observed without reaching into private state, the interface may be hiding something callers actually need. Listen to that friction before reaching for a more powerful mocking tool.
Good tests document intent, fail for the right reasons, and make refactoring boring. Aim for that, and the coverage number will mostly take care of itself.