Implementation Details and Why They Sabotage Your Tests
There was a time when Enzyme was the standard for React testing, and it was common to write tests that dug deep into component internals. APIs like instance(), state(), and find('ComponentName') were frequently used to inspect and assert on the inner workings of a component. The problem with this approach wasn't the APIs themselves, but what they allowed: testing implementation details.
The Two Failure Modes of Implementation Detail Tests
Testing implementation details introduces two serious risks to your test suite. Each one undermines the confidence your tests are supposed to provide in different ways. A test that relies on the internals of your code is vulnerable to both:
- False negatives — The test fails when the application code is actually working correctly.
- False positives — The test passes when the application code is actually broken.
Consider a simple accordion component that tracks which item is open. It maintains an openIndex in state and exposes a setOpenIndex method. A test written to treat these internals as if they were public behavior might directly assert that calling setOpenIndex updates the state to the expected value and that clicking on the accordion section updates that state correctly.
False Negatives During Refactoring
Refactoring is meant to change the implementation without changing the behavior. However, when your tests are coupled to the implementation, even a straightforward refactor can cause unnecessary test failures — a major source of the frustration many developers feel toward testing.
Imagine you're preparing the accordion to support multiple open items at once. As part of this refactor, you change the internal state structure from a single openIndex value to an openIndexes array. The component still behaves exactly the same from the user's perspective, but the state-based test will immediately break with an error stating that the expected state doesn't match what was set. This is a classic case of a false negative: your app is working perfectly fine, but your test suite is telling you it's not, forcing you to update tests that didn't verify anything meaningful in the first place.
The takeaway here is that implementation detail tests create brittle and frustrating suites that break with every refactor, adding friction to what should be a safe, routine change.
False Positives Hide Real Bugs
The more dangerous failure mode is the false positive. Suppose a co-worker spots inline arrow functions inside a render method and changes the code to avoid the potential performance downside. They run the tests and everything passes. Because the state-based test verifies that setOpenIndex updates state correctly, and the content test verifies the accordion contents are displayed when state matches, neither test checks whether the button is actually wired to the state-update function anymore. After this change, the button might no longer trigger the state update at all — breaking the component for the end user — but the test suite still passes with flying colors.
This is a false positive. The tests gave the team confidence to ship code that was fundamentally broken. And the knee-jerk reaction to this failure mode isn't pretty: adding more state-based tests, enforcing 100% code coverage, and writing lint rules to ban the APIs that encourage such testing. But these are all band-aids on the underlying problem: the tests were observing the component in a way that its actual users never would.
What Counts as an Implementation Detail?
The simplest definition you can apply is this: implementation details are the things your code's users would never ordinarily see, touch, or even know exist. To apply this definition, you first have to identify who your users are. A React component has two users: the end user who interacts with the rendered DOM, and the developer who passes the component its props. Tests should be designed to interact with the component the same way these two users do — through the public interface of props and rendered output. Simply put, they can observe the rendered text, click the buttons that are rendered, and check that the right content appears or disappears.
The moment a test reaches into an area like the component state or its internal methods, it effectively becomes a third "user" of your code. This artificial user is designed solely for the tests' benefit, and the codebase must be contorted to accommodate it — a costly burden for zero added confidence. By making your tests use the component differently than end-users and developers do, you're guaranteeing you'll be able to write code that considers the test's needs, but you're doing nothing to guarantee the actual functionality works. The more your tests resemble the way your software is used in production, the more confidence they can legitimately give you.
Choosing the Right Tool to Avoid This Problem
The best solution is to select a tool where the conventional, idiomatic path naturally steers you away from implementation details. React Testing Library is an example of such a tool. With the library's approach, a single test can verify the expected behavior comprehensively, just as an end user would experience it. The test passes regardless of whether your internal state is named openIndex, openIndexes, or something completely unrelated, because it never inspects that state. At the same time, it will correctly fail if the click handler is no longer triggering the state change, since the observable user interface won't behave as expected. This approach eliminates both false negatives and false positives without needing a memorized list of API restrictions. The default rules of the library simply align with what makes tests robust and useful.
Hooks Migration With Confidence
The shift from class components to modern hooks-based function components presents a distinct challenge to tests that rely on internals. Enzyme (once a standard tool for this kind of testing) has struggled with testing hooks properly, meaning that legacy class-based tests can't guide a refactor to hooks. When the code you're refactoring is tied tightly to its class structure, your tests are naturally the first thing to break, not the thing that catches the behavior changing. With a tool like React Testing Library, a migration of this kind is safe because the tests verify the component's public, user-visible behavior — the behavior that should remain exactly the same after an implementation-level refactor. It works identically for both class and function components since the library never queries the internals. This approach ensures your tests remain meaningful when implementation details shift beneath your feet.
A Practical Process for What to Test
Having the right tools is a fine start, but it only helps if you know what to focus on. Try to adopt the mindset of your users when deciding what's worth testing.
- Ask yourself which part of your untested codebase would be a disaster if it broke, such as the checkout process.
- Narrow that down to the specific unit or a few units of code — for example, when clicking the "checkout" button, a request with the cart items is sent to
/checkout. - Examine that code and determine who the "users" really are: the developer who renders the checkout form and the end user who clicks the button.
- Write a set of manual test instructions that a real user would follow. This might mean providing fake cart data, clicking the checkout button, mocking the
/checkoutAPI request, and verifying the success message displays. - Transform these instructions into an automated test.
These steps funnel you directly into the habit of testing the public behavior, ensuring your test suite works for its intended purpose: verifying that the software works for the people who actually use it.



