Testing React Components by Behaviour, Not Implementation
Most of us agree that having tests are good, and having great tests is amazing. But in a React application how do we write them? The line between testing behaviour and implementation can sometimes blur making our tests less valuable. We will look into how we can write valuable tests with React Testing Library.
Frontend tests are always hit or miss. Some teams may have them but don’t get much value from them. Some teams are battling against their tests. Some teams just don’t even have tests.
Tests aren’t just about ticking a ‘total coverage’ box or making leadership happy, tests allow us as developers to make changes to code and have a higher degree of confidence that our changes haven’t broken something else.
But only if we’re testing the behaviour of our React components, not the implementation.
Or as it is commonly put: When this is clicked, expect that to happen
Behavior vs Implementation
The way to think about behaviour is, when this component is on the screen and our users interact with it what should they see?
So:
- What is on the screen?
- What happens when they click, type or submit?
- What happens if the user performs an action that changes the props, how should the component respond?
- How should the component respond to browser events?
By framing our tests from the view of the user we’re automatically focusing on behaviour. 99% of users do not care about how our components are implemented, so when writing tests we shouldn’t either.
Implemetation is generally what we developers care about, its the internals of our component.
Things like:
- Did we use
useStateoruseReducer? - What variable names did we use?
- Particular HTML structure we chose
- If we used a custom hook or not.
This kind of topics are great in PR reviews or team discussions, but they have no place when testing our components.
Whenever we’re in doubt when writing tests we should ask ourselves, ‘is this something the user will see and/or care about?‘
Enter React Testing Library
React Testing Library is basically built around a single idea:
The more your tests resemble how your software is used, the more confidence they can give you.
This reiterates what we discussed above:
- Render components like a user would observe them (via the DOM)
- Interact with them like a user would (click, type, tab, etc.)
- Assert on what a user can observe (text, roles, form values, aria attributes)
And avoid:
- Spying on hooks
- Poking into component instances
- Asserting on internal state
Lets look at a simple example:
import { useState } from "react";
type CounterProps = {
initial?: number;
};
export function Counter({ initial = 0 }: CounterProps) {
const [count, setCount] = useState(initial);
const incrementClickHandler = () => setCount((prevCount) => prevCount + 1);
const decrementClickHandler = () => setCount((prevCount) => prevCount - 1);
return (
<div>
<p aria-label="count">Count: {count}</p>
<button onClick={incrementClickHandler}>Increment</button>
<button onClick={decrementClickHandler}>Decrement</button>
</div>
);
}
We’ll use the well known simple counter as our first example, and put together some behaviour driven tests:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
test("increments and decrements the count", async () => {
const user = userEvent.setup();
render(<Counter initial={5} />);
expect(screen.getByLabelText("count")).toHaveTextContent("Count: 5");
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByLabelText("count")).toHaveTextContent("Count: 6");
await user.click(screen.getByRole("button", { name: /decrement/i }));
expect(screen.getByLabelText("count")).toHaveTextContent("Count: 5");
});
We first test that when the component renders the user will see what we expect, Count: 5.
Then test what happens when the user clicks each button.
We haven’t testing the DOM structure, we didn’t peak into the internal state, we only mimicked what a user would do.
One way we can think about this as developers is, when we want to test our implementations what do we do? Well we start the development server, and check to see how the application behaves. We click buttons, navigate between pages, add, delete, we click through any of the features we’ve added. These checks we’re doing often can become the component tests we write.
What to avoid: Testing implementation details
// ❌ Don't do this
test("increments and decrements (brittle)", async () => {
const user = userEvent.setup();
const { container } = render(<Counter />);
// Relying on DOM structure / internal markup
const countElement = container.querySelector("p");
expect(countElement?.textContent).toBe("Count: 0");
const [incrementButton, decrementButton] =
container.querySelectorAll("button");
await user.click(incrementButton!);
expect(countElement?.textContent).toBe("Count: 1");
await user.click(decrementButton!);
expect(countElement?.textContent).toBe("Count: 0");
});
In the above example we’re violating a lot of the rules we covered in previous sections.
- Relies on p and button being in a particular order/structure.
- Breaks if we wrap things in another element, add icons, or change HTML tags.
- Not aligned with how a user sees elements (they don’t think ‘first button in DOM’).
React testing library provides the tools we need to query elements by role, label and text and in most cases we should always prefer that over using container.querySelector.
Conclusion
This was a short primer into testing behaviour vs implementation, which is the foundation of building tests, following this we ensure we have:
- Tests that survive refactors
- Freedom to restructure our components and hooks
- A mindset that matches how real users interact with our UI
In future posts we will dive deeper into testing, learning how we can test our hooks, how we can mock api and how to write end to end tests. But in every case we will follow the same pattern:
Test behaviour, not implementation