Testing Stateful React Function Components with React Testing Library

With the introduction of React Hooks, there is more of an incentive to write function components in React since there is no longer a need use classes when building components. When it comes to testing React components, there are two popular libraries that are often reached for: enzyme and react-testing-library. I used to always reach for enzyme when testing my react components, but I've recently made the switch to react-testing-library because the react-testing-library API encourages tests that are completely ignorant of implementation details. Why is this good? Testing implementation details can lead to both false negatives and false positives, which make tests much more unreliable.

In this post, I'll look at an example stateful function component that is tested with react-testing-library. I'll also write the same component into its class component equivalent and show how the class component can be tested with enzyme.

Checklist Example

Here's a checklist component that allows a user to check off items and display a message after all the items have been checked.

Note: All these examples are written in TypeScript.

tsx
1export const Checklist = ({ items }: ChecklistProps) => {
2 const [checklistItems, setChecklistItems] = useState(items);
3
4 const handleClick = (itemIndex: number) => {
5 const toggledItem = { ...checklistItems[itemIndex] };
6 toggledItem.completed = !toggledItem.completed;
7 setChecklistItems([
8 ...checklistItems.slice(0, itemIndex),
9 toggledItem,
10 ...checklistItems.slice(itemIndex + 1)
11 ]);
12 };
13
14 // Determine if all tasks are completed
15 const allTasksCompleted = checklistItems.every(({ completed }) => completed);
16
17 return (
18 <div>
19 <form>
20 {checklistItems.map((item, index) => (
21 <React.Fragment key={item.description}>
22 <input
23 onChange={() => handleClick(index)}
24 type="checkbox"
25 className="checkbox"
26 checked={item.completed ? true : false}
27 id={item.description}
28 />
29 <label htmlFor={item.description}>{item.description}</label>
30 </React.Fragment>
31 ))}
32 </form>
33 <TasksCompletedMessage
34 className="xs-text-4 text-green xs-mt2"
35 visible={allTasksCompleted}
36 >
37 All tasks completed{" "}
38 <span role="img" aria-label="checkmark">
39
40 </span>
41 </TasksCompletedMessage>
42 </div>
43 );
44};

Here's what the component would look like when used:

Checklist with two unchecked items.
Checklist with two unchecked items.

Checklist with two checked items and a message indication all items are checked.
Checklist with two checked items and a message indicating all items are checked.

Now when I'm thinking of testing this component, I want to make sure that a user is able to properly select a checkbox and also display the completed message when all the items have been checked. Here's how these tests would look like when written with react-testing-library:

tsx
1afterEach(cleanup);
2
3const mockItems = [
4 {
5 description: "first item",
6 completed: false
7 },
8 {
9 description: "second item",
10 completed: false
11 },
12 {
13 description: "third item",
14 completed: false
15 }
16];
17
18describe("Checklist", () => {
19 it("should check two out the three checklist items", () => {
20 const { getByText, getByLabelText } = render(
21 <Checklist items={mockItems} />
22 );
23
24 fireEvent.click(getByText("first item"));
25 fireEvent.click(getByText("second item"));
26
27 expect(getByLabelText("first item").checked).toBe(true);
28 expect(getByLabelText("second item").checked).toBe(true);
29 expect(getByLabelText("third item").checked).toBe(false);
30 expect(getByText("All tasks completed")).not.toBeVisible();
31 });
32
33 it("should display a message when all items are completed", () => {
34 const { getByText, getByLabelText } = render(
35 <Checklist items={mockItems} />
36 );
37
38 fireEvent.click(getByText("first item"));
39 fireEvent.click(getByText("second item"));
40 fireEvent.click(getByText("third item"));
41
42 expect(getByLabelText("first item").checked).toBe(true);
43 expect(getByLabelText("second item").checked).toBe(true);
44 expect(getByLabelText("third item").checked).toBe(true);
45 expect(getByText("All tasks completed")).toBeVisible();
46 });
47});

There are a few special things of note in these tests. The first being how we are targeting elements on the page by their text rather than by a class name, id, or other DOM selector. This is important because this is actually how a user will find an element on the page. A user doesn't see or care about what classes or ids are found on an element so it's unrealistic to expect a user to find and interact with an element on a page based on a DOM selector.

react-testing-library doesn't only allow you to target elements by text, but you can also target elements through labels, placeholder text, alt text, title, display value, role, and test id (see the documentation for details on each of these methods of targeting elements).

Another important thing to notice in these tests is that we aren't looking at the value for the internal component state, nor are we testing any of the functions being used within the component itself. Basically what this means is that we don't care about testing the implementation details of our component, but we are more interested in testing how the component will actually be used by a user. Actually, it's extremely difficult to test implementation details of a function component since it's not possible to access the component state, nor can we access any of the functions/methods that are defined and used inside of the component. However, as a fun exercise, let's look at our checklist component written in as a class component:

tsx
1export class Checklist extends React.Component<ChecklistProps, ChecklistState> {
2 state = {
3 checklistItems: this.props.items
4 };
5
6 handleChange = (itemIndex: number) => {
7 const toggledItem = { ...this.state.checklistItems[itemIndex] };
8 toggledItem.completed = !toggledItem.completed;
9 this.setState({
10 checklistItems: [
11 ...this.state.checklistItems.slice(0, itemIndex),
12 toggledItem,
13 ...this.state.checklistItems.slice(itemIndex + 1)
14 ]
15 });
16 };
17
18 render() {
19 // Determine if all tasks are completed
20 const allTasksCompleted = this.state.checklistItems.every(
21 ({ completed }) => completed
22 );
23 return (
24 <div>
25 <form>
26 {this.state.checklistItems.map((item, index) => (
27 <React.Fragment key={item.description}>
28 <input
29 onChange={() => this.handleChange(index)}
30 type="checkbox"
31 className="checkbox"
32 checked={item.completed ? true : false}
33 id={item.description}
34 />
35 <label htmlFor={item.description}>{item.description}</label>
36 </React.Fragment>
37 ))}
38 </form>
39 <TasksCompletedMessage
40 className="xs-text-4 text-green xs-mt2"
41 visible={allTasksCompleted}
42 >
43 All tasks completed{" "}
44 <span role="img" aria-label="checkmark">
45
46 </span>
47 </TasksCompletedMessage>
48 </div>
49 );
50 }
51}

Now, let's use enzyme to test our checklist class component. However, this time we will be testing the implementation details of our component.

tsx
1const mockItems = [
2 {
3 description: "first item",
4 completed: false
5 },
6 {
7 description: "second item",
8 completed: false
9 },
10 {
11 description: "third item",
12 completed: false
13 }
14];
15
16describe("Checklist Class Component", () => {
17 it("should render all 3 list items", () => {
18 const wrapper = mount(<Checklist items={mockItems} />);
19
20 expect(wrapper.find("label").length).toBe(3);
21 });
22
23 describe("handleChange", () => {
24 it("should check two out the three checklist items", () => {
25 const wrapper = mount(<Checklist items={mockItems} />);
26 const instance = wrapper.instance();
27
28 instance.handleChange(0);
29 instance.handleChange(1);
30
31 expect(wrapper.state("checklistItems")).toEqual([
32 {
33 description: "first item",
34 completed: true
35 },
36 {
37 description: "second item",
38 completed: true
39 },
40 {
41 description: "third item",
42 completed: false
43 }
44 ]);
45 });
46
47 it("should display a message when all items are completed", () => {
48 const wrapper = mount(<Checklist items={mockItems} />);
49 const instance = wrapper.instance();
50
51 instance.handleChange(0);
52 instance.handleChange(1);
53 instance.handleChange(2);
54 wrapper.update();
55
56 expect(
57 wrapper
58 .find(".text-green")
59 .first()
60 .props().visible
61 ).toBe(true);
62 });
63 });
64});

Because the enzyme API makes available a component's state as well as the class methods of component (by accessing the component's instance), we are now able to test both those things. For example, looking at the test labelled should check two out the three checklist items, the handleChange method is triggered twice (which should happen when a user clicks two checklist items) and then the value of the state is checked to make sure it has updated appropriately. The problem with this test is that we aren't testing how this component is actually being used. The user doesn't care about the value of a component's internal state or if a function has been called. All a user cares about (in this case) is that they are able to click on two checklist items and that both those checklist items appear as checked to them.

Enzyme's API doesn't allow for an element to be find by it's text, it only allows for elements to be selected based on a CSS selector, React component constructor, React component display name, or based on a component's props (see here for details on Enzyme selectors). Because Enzyme's API basically pushes you to test implementation details for a component, I prefer to stay away from Enzyme and instead use react-testing-library.

Refactoring Class Components to Function Components

Another advantage of using react-testing-library and not testing for implementation details is that you can easily refactor your class component to a function component without having the also refactor your tests. Think about it, if you're targeting class methods in your tests, those methods will no longer be available when it's being implemented within a function component.

Demo Repository

I've setup a demo repository, that contains the above example with the checklist and I've also created another example for a component named SelectTwo, which is a list of items that only allows for 2 items to be selected at once.

Other Ressources

Here are some great ressources that you should check out if you're interested in learning more about react-testing-library.



Robert Cooper's big ol' head

Hey, I'm Robert Cooper and I write articles related to web development. If you find these articles interesting, follow me on Twitter to get more bite-sized content related to web development.