Should I test that?

Should I test implementation details?

Verdict

No

Do not write tests that assert on implementation details such as internal calls, private state or call order; test the behaviour that callers can observe through the public interface.

Why

Do not write tests that assert on implementation details; assert on the behaviour that callers can observe. In the typical case, a service class whose public methods have unit tests, Blast radius is users and Change frequency is regularly, because a wrong result reaches customers and the internals change about once a month. Detectability is immediately and Reversibility is trivial, because an internal mistake that changes the result fails a behaviour test in CI before the change merges. Test cost is moderate: a test that pins internal calls needs mocks and a rewrite on each refactor, even when no behaviour changed. Rule R8 gives Do not test.

When the decision changes
WhenDecisionWhy
The service charges a card, and a second gateway call would charge twiceTest mandatory: assert with a fake gateway that one order makes exactly one chargeBlast radius rises to money and Reversibility to costly
The detail is how passwords are stored, which no login result revealsTest mandatory: assert that the stored value is a hash, never the plain passwordBlast radius rises to safety-or-legal and Detectability to never
The service sends a confirmation email before the database transaction commitsTest: assert with a fake mailer that no email goes out when the save failsReversibility rises to impossible and Detectability to eventually
A refactor can remove a cache in front of a slow API while every result stays correctTest minimally: one test that two identical lookups make one call to a fake APIDetectability moves to eventually, because only slower pages reveal the missing cache
The class has no behaviour tests, and releases are checked by handTest minimally: one test of the public behaviour, not of the internalsDetectability moves to same-day, because only a user report reveals the bug
A mocking library turns a check of internal calls into a one-line testDo not test the internal calls; keep the behaviour testsTest cost falls to trivial, but Detectability stays immediately and Reversibility trivial

What breaks if you don't test

Behaviour tests already fail on any internal mistake that changes the result. The gap is internal steps whose effect never reaches the return value. A service that sends the confirmation email before the database commit passes every result check, and customers get emails for orders that were rolled back.

What you lose if you over-test

A test that pins internal calls fails on refactors that change no behaviour. Replace one save() per item with a single saveAll(), and every test that verified save() turns red although the stored data is identical. After a few of these, developers edit verifications to match the code without reading them, and a red test stops meaning a bug. Martin Fowler describes this coupling in Mocks Aren't Stubs. The larger loss is false confidence: a check that a method was called passes while the method receives the wrong data.

What to do instead

Test through the public interface and assert on outcomes: the returned value, the state of an in-memory fake, or the message that leaves the system. Use a mock only at a boundary where the call is the behaviour, such as a payment gateway or a mailer. For UI components, query what the user sees, as the Testing Library guiding principles describe. Chapter 12 of Software Engineering at Google covers both rules under "Test via Public APIs" and "Test State, Not Interactions".

When the answer changes

  • The internal step has an effect you cannot undo, such as a card charge or an email.
  • The internal step protects data or access, such as password hashing.
  • Correct results hide a regression, such as a removed cache or an extra query per row.

Real incident + Code example

The renewal that saved the old date

On a subscription billing service I worked on, the tests of renew() mocked the repository and checked verify(repository).save(any()). A refactor made Subscription immutable, and renew() built a copy with the new end date but still saved the original. The tests stayed green, because the check proved that save() ran, not what it stored. The nightly expiry job suspended customers who had just paid, and we restored their accounts with a script the next morning. We replaced the interaction checks with a state check:

@Test
void renewExtendsEndDateByOneMonth() {
    var repository = new InMemorySubscriptionRepository();
    repository.save(new Subscription("s1", LocalDate.of(2026, 3, 31)));

    new RenewalService(repository).renew("s1");

    // plusMonths(1) clamps March 31 to April 30
    assertEquals(LocalDate.of(2026, 4, 30), repository.find("s1").endDate());
}

The test fails whenever the stored end date is wrong, however renew() builds it.

FAQ

Should one test internal implementation, or only public behaviour?

Test only public behaviour in the typical case: return values, stored state, and messages that leave the system. Test an internal step directly only when its effect cannot be undone or cannot be seen in the result, such as a card charge or a password hash.

Should unit tests test implementation?

No, unit tests should check what a unit returns or changes, not which internal calls it makes. A behaviour test survives a rewrite of the internals and fails only when the result changes.

Should I verify mock calls in unit tests?

Verify a mock call only when the call is the behaviour, such as a charge sent to a payment gateway or an email handed to a mailer. For calls that read or store data inside your system, assert on the returned value or on the state of an in-memory fake.

How do I test behaviour instead of implementation?

Call the public method as a caller would, then assert on what the caller can observe: the result, the saved record, or the HTTP response. Use an in-memory fake of the database, so the test can read what was saved.