ConstructiCat Logo
CodeBust.
Browse section ▾

Testing Implementation Details.

A test asserts on how the code works internally — private fields, internal method calls, DOM structure, or CSS classes — instead of the observable behavior a real consumer depends on.

##Signs and Symptoms

You recognize this smell when a test reaches past the public contract and pins down the machinery behind it. Common tells:

  • Assertions on private/internal state or fields, or invoking private methods directly (often via casts, reflection, or // @ts-expect-error).
  • Spying on or asserting that an internal helper was called (expect(internalCalc).toHaveBeenCalled()) instead of checking the result.
  • UI tests querying by CSS class, tag, data-* hooks, or DOM position rather than by role/label/text — e.g. container.querySelector('.btn-primary > span:nth-child(2)'), wrapper.state(), wrapper.find('SomeChildComponent').props().
  • Snapshot tests over entire render trees / serialized internal objects, so any markup tweak fails.
  • Tests that break on every refactor even though the feature still works (false negatives), and conversely keep passing after a logic bug because they only re-check the wiring (false positives).
// SMELL: couples the test to component internals and DOM structure
test('counter increments', () => {
  const wrapper = mount(<Counter />);
  wrapper.instance().handleClick();          // calls a private method directly
  expect(wrapper.state('count')).toBe(1);    // asserts on internal state
  expect(wrapper.find('.count-display').text()).toBe('1'); // brittle CSS selector
});

The same shape appears server-side: expect(service._cache.size).toBe(1) or asserting the exact sequence of internal calls a method makes.

##Reasons for the Problem

Why it happens

  • Internal state is easy to reach — a public field, an exported helper, or container.querySelector is right there, while exercising the real behavior takes more setup.
  • Chasing coverage metrics: testing each private method 1:1 feels thorough.
  • Heavy mocking pushes people toward asserting "was this collaborator called" instead of "did the right thing happen."
  • Tooling that encourages it: shallow rendering / instance() / state() APIs, or grabbing nodes by class name.

Why it hurts

  • Maintainability / fragility. This is Meszaros's Fragile Test driven by Overspecified Software: the test pins behavior the consumer never required, so harmless refactors (rename a method, restructure markup, change a private field) break green tests for no real reason. Tests become a tax on refactoring rather than a safety net.
  • False confidence (the core danger, per Kent C. Dodds). Implementation-detail tests fail in both wrong directions: false negatives (test goes red though the feature still works) and false positives (test stays green though the feature is broken — you asserted the wiring, not the outcome). Either way the suite stops telling you the truth.
  • Readability. The test documents how the code is built, not what it guarantees. A reader can't tell which behavior actually matters, and the test no longer doubles as a usage example or spec.
  • Coupling. It locks in current design decisions, discouraging exactly the refactors tests are supposed to make safe.

##Treatment

Test through the public contract — the same surface a real caller or user touches — and assert on observable output: return values, thrown errors, emitted events, persisted state, or rendered/visible UI.

  1. Identify the consumer. For a module, that's its exported API; for a UI component, it's the user (clicks, typing) and what they can see.
  2. Drive inputs the way a consumer would, not by calling private methods. Trigger a real click instead of invoking the handler; call the public method instead of the helper.
  3. Assert on results, not internals. Replace state()/private-field/toHaveBeenCalled checks with checks on what comes out.
  4. Query UI by accessibility, not structure — role, label, text — instead of CSS classes, tags, or nth-child.
  5. Stop testing private methods directly. Cover them through the public method that uses them; if a private unit is complex enough to need its own tests, that's a signal to extract it into its own module with its own public API.
  6. Reserve mock/spy assertions for true boundaries (network, time, payment gateway) where the call itself is the observable behavior — not for internal collaborators.
// BEFORE: tests implementation details
const wrapper = mount(<Counter />);
wrapper.instance().handleClick();
expect(wrapper.state('count')).toBe(1);
expect(wrapper.find('.count-display').text()).toBe('1');

// AFTER: tests observable behavior via the public, user-facing contract
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('1')).toBeInTheDocument();

Rule of thumb: if a behavior-preserving refactor breaks the test, the test was asserting an implementation detail.

##Detected by