You’ve got a solid test suite. Coverage reports look healthy. CI pipeline glows green. Yet every refactor breaks a dozen tests, and you’re spending more time fixing assertions than writing features. Sound familiar? You’re probably testing the wrong things.
Most React developers fall into a trap: they write tests that mirror component internals instead of validating what users actually see and do. The result is a brittle suite that slows you down and delivers false confidence. Time to cut the dead weight.
The Implementation Detail Trap
Walk through any mid-sized React codebase and you’ll stumble on tests like this:
test('increments counter', () => { const wrapper = shallow(<Counter />); wrapper.instance().handleIncrement(); expect(wrapper.state('count')).toBe(1);});
It passes. It’s also worthless. This test checks that a specific method updates a specific piece of state. Rename handleIncrement to onIncrementClick? Test breaks. Swap class state for useReducer? Test breaks. Extract the logic into a custom hook? Test breaks. The user-facing behavior—click a button, see a number go up—hasn’t changed one bit, but your suite screams at you anyway.
Implementation-detail tests handcuff you to the current code structure. Every refactor becomes a chore. Worse, they give you a false sense of security: all tests pass, but nobody checked that the button is actually on the screen or that clicking it updates the DOM correctly.
What You Should Be Testing Instead
Flip your perspective. Stop asking “how does this component work internally?” and start asking “what does the user see and do?”. Users don’t know about state variables, lifecycle methods, or hook internals. They click buttons, type in fields, and expect things to appear, disappear, or change.
Here’s that same counter test, rewritten from the user’s point of view:
test('increments displayed count when button is clicked', () => { render(<Counter />); const button = screen.getByRole('button', { name: /increment/i }); fireEvent.click(button); expect(screen.getByText('Count: 1')).toBeInTheDocument();});
This test survives renaming handlers, swapping state management, or even rewriting the component in a completely different pattern. It checks the contract between your component and the person using it. That’s the only contract that matters.
Three Patterns That Corrode Your Test Suite
1. Testing State Directly
Accessing component.state or spying on useState setters ties your test to a specific state management approach. Adopt useReducer, Redux, or URL-based state later, and these tests become noise. Instead, assert on the rendered output. If a state change should hide an element, check that the element is gone. If it should disable a button, check the button’s disabled attribute.
2. Mocking Child Components
Jest mocks like jest.mock('./ChildComponent') replace real components with empty shells. Your test now verifies that a mock was rendered, not that the actual child works correctly. This hides integration bugs. If you must mock, mock at the network or storage boundary—not your own components.
3. Testing Props Passed to Children
Asserting that <ChildComponent> received propX=5 is another form of implementation testing. The user doesn’t care about props. They care that when they click “Save,” a success message appears. Test the message, not the prop plumbing.
Practical Refactors for Common Scenarios
Forms: Test Submission, Not State Updates
Bad test:
const setEmail = jest.fn();render(<EmailInput value= onChange={setEmail} />);fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test@example.com' } });expect(setEmail).toHaveBeenCalledWith('test@example.com');
This verifies that the onChange prop is called. It doesn’t verify that the input actually accepts text. A better approach: render the full form, type into the field, submit, and check the resulting UI or network call.
test('submits email address', async () => { render(<NewsletterForm />); fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'test@example.com' }, }); fireEvent.click(screen.getByRole('button', { name: /subscribe/i })); await screen.findByText(/thank you/i);});
This test covers the entire flow: render, interact, observe result. It works regardless of whether the form uses controlled inputs, uncontrolled inputs, Formik, or React Hook Form.
Conditional Rendering: Test Visibility, Not Flags
If your component shows a spinner while loading, don’t check isLoading state. Check that the spinner element exists when loading and disappears when data arrives.
test('shows spinner while fetching data', async () => { render(<DataLoader />); expect(screen.getByRole('progressbar')).toBeInTheDocument(); await screen.findByText('Data loaded'); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();});
Use findBy* queries for async appearances and queryBy* for elements that should be absent. These queries are built into Testing Library and naturally wait for DOM updates.
Custom Hooks: Test Through a Component
Testing a custom hook in isolation often requires creating a fake component that uses the hook and then asserting on the hook’s return values. That’s fragile. Instead, test the hook indirectly by testing the components that consume it. If the hook is shared across many components, pick the simplest one and write integration tests there. If the hook is complex enough to warrant standalone tests, use renderHook from Testing Library—but still assert on the hook’s public API, not its internal state transitions.
What About Unit Tests?
“But I need unit tests for my utility functions!” Yes, you do. Pure functions that transform data—formatters, validators, calculation helpers—are perfect candidates for traditional unit tests. They have clear inputs and outputs, no DOM, no React. Test them exhaustively. The distinction is simple: if a function touches the DOM or React internals, test it through rendered output. If it’s pure JavaScript, test it directly.
Coverage Metrics Are Lying to You
Code coverage tells you which lines were executed during tests. It doesn’t tell you whether those lines were verified. You can get 100% coverage by rendering a component and never making a single assertion. Coverage is a useful signal for finding untested code paths, but it’s a terrible measure of test quality. Stop chasing the number. Start asking: “If someone refactors this component, will my tests catch regressions in user-visible behavior?”
Building a Test Suite That Ages Well
Here’s a practical checklist for each test you write or review:
- Does the test interact with the component the way a user would? Use
getByRole,getByLabelText,getByTextinstead of CSS selectors or test IDs. - Does the test assert on something the user can see or experience? Text content, element presence, attributes like
disabledoraria-expanded. - Would the test still pass if I rewrote the component using a different pattern? If you swap
useStateforuseReducer, does the test break? If yes, rewrite it. - Is the test isolated from unrelated changes? A test for the login form shouldn’t fail because you changed the header component.
Common Excuses and Why They’re Wrong
“But I need to test that my reducer returns the correct state.” No, you need to test that the UI reflects the correct state after an action. Extract your reducer and test it as a pure function. Then test the component by triggering actions and checking the rendered output.
“Shallow rendering is faster.” It’s also less reliable. The milliseconds you save per test are dwarfed by the hours you’ll spend fixing broken tests after every refactor. With modern tools like React Testing Library and jsdom, full rendering is fast enough for thousands of tests.
“I need to test that my component passes the right props to its child.” You need to test that the child renders correctly when used inside the parent. If the child is a third-party component, trust its own tests. If it’s your component, write an integration test that covers the parent-child interaction.
When Mocks Make Sense
Not all mocks are evil. Mocking at the boundary of your system is often necessary and healthy:
- API calls: Use Mock Service Worker (MSW) to intercept network requests. Your components still make real fetch calls, but the responses are controlled.
- Browser APIs: Mock
localStorage,matchMedia, orIntersectionObserverwhen your test environment doesn’t support them. - Third-party services: Mock SDKs for analytics, payment processors, or maps. You’re not testing Stripe’s code.
The rule: mock what you don’t own, not what you do.
Refactoring an Existing Suite
You don’t need to rewrite everything overnight. Start with the tests that break most often. When a test fails during a legitimate refactor, replace it with a behavior-focused version. Over time, your suite will become more resilient.
Also, delete tests that don’t add value. A test that asserts typeof component === 'function' is noise. A test that checks default props that are never used by users is noise. Be ruthless. Every test should earn its place by protecting against a regression that a user would notice.
FAQ
Should I stop using snapshot tests entirely?
Not necessarily, but use them sparingly. Snapshot tests are brittle because they capture every detail of the rendered output. They’re useful for small, stable components where the entire output is meaningful—like a styled button or an icon. For larger components, they create noise. If you do use them, review snapshot diffs carefully and update them only when the change is intentional.
How do I test components that use context?
Wrap the component in its required context provider during testing. Testing Library’s render function accepts a wrapper option for this. Better yet, test the component within a realistic parent that already provides the context. This catches mismatches between what the component expects and what the provider actually supplies.
What about testing error boundaries?
Error boundaries are one of the few cases where testing implementation is acceptable—you need to verify that errors are caught and a fallback UI is shown. Use jest.spyOn(console, 'error') to suppress expected error logs, then render a component that throws inside the boundary. Assert that the fallback UI appears and the console error was called.
Images



Your test suite should be a safety net, not a straitjacket. When tests break because you improved the code’s internal structure without changing its behavior, those tests are working against you. Write tests that trust the DOM. Your future self—and anyone who inherits your codebase—will thank you.