š§Ŗ Avoiding Implementation Details in Your React Tests
When writing tests for your React components, thereās one golden rule that can make your code more robust, flexible, and maintainable:
ā Donāt test implementation details. ā Test what the user sees and does.
Letās break down what that means, why it matters, and how to fix tests that break unnecessarily when you refactor your components.
Implementation details are the internal workings of your code ā how an outcome is achieved. In contrast, public behavior is what the end user sees or interacts with.
Hereās an example with a simple utility function:
multiply(4, 5) // 20
The implementation of multiply()
could be anything:
const multiply = (a, b) => a * b
Or even:
function multiply(a, b) {let total = 0for (let i = 0; i < b; i++) {total += a}return total}
The point? The user doesnāt care how you got the result ā just that itās correct.
Now letās apply this principle to React component testing.
Hereās a simple Counter
component:
function Counter() {const [count, setCount] = React.useState(0)const increment = () => setCount(c => c + 1)return <button onClick={increment}>{count}</button>}
In our test, we might do this:
const { container } = render(<Counter />)fireEvent.click(container.firstChild)expect(container.firstChild.textContent).toBe('1')
But what if we refactor?
function Counter() {const [count, setCount] = React.useState(0)const increment = () => setCount(c => c + 1)return (<span><button onClick={increment}>{count}</button></span>)}
š„ Boom. The test fails ā even though the component works just fine for the user. Why? Because we wrote the test based on the componentās structure ā an implementation detail.
Letās fix that using React Testing Libraryās user-centric queries.
import { render, screen, fireEvent } from '@testing-library/react'import Counter from '../counter'test('increments the counter', () => {render(<Counter />)const button = screen.getByRole('button', { name: '0' })fireEvent.click(button)expect(button).toHaveTextContent('1')})
screen.getByRole('button', { name: '0' })
matches how users experience the UIš Learn more about
screen
: https://testing-library.com/docs/dom-testing-library/api-queries#screen
Even calling fireEvent.click(button)
isnāt quite how users interact. When a user clicks a button, they:
Thatās a lot of interaction, and fireEvent.click()
only simulates one event.
So letās go a step further.
@testing-library/user-event
This library simulates real user behavior more accurately.
import userEvent from '@testing-library/user-event'test('increments the counter', async () => {render(<Counter />)const button = screen.getByRole('button', { name: '0' })await userEvent.click(button)expect(button).toHaveTextContent('1')})
userEvent
š§Ŗ Explore how it works behind the scenes:
click()
source code
ā Bad Practice | ā Good Practice |
---|---|
Query by container.firstChild | Use screen.getByRole or screen.getByText |
Assert with textContent === ... | Use toHaveTextContent() |
Use fireEvent.click() | Use await userEvent.click() |
Break tests when HTML changes | Build refactor-friendly, stable tests |
Tests are only valuable if they continue to give you confidence after a refactor.
That means writing tests that focus on what the user sees and does, not how your component is built.
So next time you write a test, ask yourself:
āWould this still pass if I changed the HTML structure but kept the same behavior?ā
If not, itās time to ditch the implementation details.
Ready to practice this skill and retain what you learned? Fill out the elaboration and feedback form:
š Avoid Implementation Details ā Feedback Form
Quick Links
Legal Stuff
Social Media