A test should describe what the code is supposed to do, not how it currently happens to do it. If your tests are tightly coupled to implementation details — private methods, internal data structures, the specific sequence of calls to a particular class — then the moment you improve or refactor the implementation, perfectly correct code causes tests to fail, not because behaviour changed, but because you tested the wrong thing.

This is a common and costly mistake: a team refactors code confidently, all functionality still works exactly as intended, and yet dozens of tests break. The reflex is to distrust the refactor, when in fact the tests were faulty from the start. Tests like this actively punish good practice — they make refactoring, which should always leave the codebase in a better state, feel dangerous.

Test through the public interface of what you are testing — the way a real caller would use it. Give it inputs, and assert on outputs and observable side effects. If you find yourself needing to inspect or mock the internals of the very thing you're testing, that is normally a sign that you are testing implementation rather than functionality.

There's a nuance here worth being upfront about: testing at a unit level almost always means calling internal application code directly rather than exercising it purely through an external interface like an HTTP endpoint, and that's fine — "implementation" in this rule means the internal mechanics of how a unit does its job (which private helper it calls, what internal state it mutates, the order operations happen in), not the boundary at which you choose to test. Test the contract a unit of code makes with its caller: given these inputs (and this starting state), these outputs (and these resulting side effects) are guaranteed — regardless of how that unit chooses to do its job internally.

Done well, this gives you the freedom to change implementation at will — rewrite an algorithm, swap a data structure, split a function into three — without ever having to touch the test suite, because the contract with the caller never changed.

Summary

  • Test the contract a unit of code makes with its caller — given these inputs, these outputs and side effects are guaranteed
  • Never assert on private methods, internal state, or the specific sequence of internal calls
  • If refactoring correct code breaks your tests, the tests were testing implementation, not functionality
  • Good tests should let you change the implementation freely without ever touching the test suite
  • Tests that punish refactoring make a team afraid to improve their own code