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.querySelectoris 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.
- 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.
- 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.
- Assert on results, not internals. Replace
state()/private-field/toHaveBeenCalledchecks with checks on what comes out. - Query UI by accessibility, not structure — role, label, text — instead of CSS classes, tags, or
nth-child. - 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.
- 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
- eslint-testing-library testing-library/no-node-access — no-node-access
- eslint-testing-library testing-library/no-container — no-container