Enzyme vs React Testing Library: A Migration Guide

Updated on · 14 min read
Enzyme vs React Testing Library: A Migration Guide

When it comes to testing React components, developers have a few options at their disposal. Two popular choices are Enzyme and React Testing Library. However, with the release of React 18, it looks like the time of Enzyme is coming to an end, as the changes in the React API require a major rewrite of Enzyme. Thus, those who want to take advantage of React's new features have to migrate their Enzyme tests to React Testing Library. It looks like in the debate "Enzyme vs React Testing Library", the latter has emerged as the clear winner.

This blog post provides a basic comparison of both libraries and presents a migration guide based on code samples from each library. We'll write tests for the Tic-Tac-Toe game from an earlier post using Enzyme and then transform them into React Testing Library tests. As a refresher, the final version of the game is available on GitHub Pages, and the code (together with the added tests) can be found on GitHub as well as on CodeSandbox.

Enzyme vs React Testing Library

Enzyme and React Testing Library are two popular choices for testing React components. While both tools serve the same end purpose, their approaches have a different focus. Enzyme has a more traditional unit test-driven approach, allowing developers to traverse and manipulate component hierarchies to simulate user interactions and test component behavior in isolation. React Testing Library, on the other hand, encourages developers to write tests that closely resemble how users interact with the application, focusing on testing the behavior of components. While Enzyme has been a go-to choice for many developers for years, React Testing Library was gaining more and more popularity, especially recently, considering that the Enzyme does not support the latest version of React.

Should I still use Enzyme?

While Enzyme is a powerful and flexible tool, the release of React 18 has made it less appealing for those who want to take advantage of the latest React features because Enzyme simply does not support React 18. Moreover, Enzyme does not have official support even for React 17, relying on a third-party @wojtekmaj/enzyme-adapter-react-17 package, which has become the de facto standard for testing React 17 with Enzyme. Apparently, Enzyme relies to a great extent on React's internal APIs, which were drastically changed in the latest major versions. The lengthy discussion about this can be seen in the GitHub issue about adding enzyme-adapter-react-17. On top of that, the author of the unofficial enzyme-adapter-react-17 published a blog post urging everybody to consider migrating away from Enzyme due to its lack of support for the latest version of React, while questioning the future of Enzyme altogether.

Based on the above, the answer to whether you should still use Enzyme is probably no. Not only is the support for React Testing Library currently better, but it is also in many cases a superior choice for testing React components due to its simplicity, ease of use, and focus on more integration style of testing. The only reason I can think of to stick with Enzyme is if you have an established and particularly large testing suite with Enzyme and never plan to use React versions above 17. Even in this case, I think it could be beneficial to consider migrating away from Enzyme, even though its usage is still quite high and is unlikely to dramatically change anytime soon.

Adding Enzyme tests

As mentioned before, we will use the Tic-Tac-Toe game from an earlier tutorial and add both Enzyme and React Testing Library tests to it, starting with Enzyme. Through this process, we will see the main difference in approach to testing with both libraries, and the test code itself will be used as a migration example. Due to incompatibility with React 18 and above, we will need to downgrade the app to use React 17. Luckily this process is quite simple - we will install react@17 and react-dom@17 and switch from the new createRoot API to the old ReactDOM.render, as stated in the React documentation. Let's start with that and modify the index.tsx file:

bash
npm i react@17 react-dom@17
bash
npm i react@17 react-dom@17
tsx
// index.tsx // Before import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; import Modal from "react-modal"; Modal.setAppElement("#root"); const container = document.getElementById("root"); if (container) { const root = createRoot(container); root.render(<App />); }
tsx
// index.tsx // Before import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; import Modal from "react-modal"; Modal.setAppElement("#root"); const container = document.getElementById("root"); if (container) { const root = createRoot(container); root.render(<App />); }
tsx
// index.tsx // After import { render } from "react-dom"; import "./index.css"; import App from "./App"; import Modal from "react-modal"; Modal.setAppElement("#root"); const container = document.getElementById("root"); if (container) { render(<App />, container); }
tsx
// index.tsx // After import { render } from "react-dom"; import "./index.css"; import App from "./App"; import Modal from "react-modal"; Modal.setAppElement("#root"); const container = document.getElementById("root"); if (container) { render(<App />, container); }

As mentioned earlier, @wojtekmaj/enzyme-adapter-react-17 is the de facto standard for writing tests for React 17, so we will add that and install other required packages and their dependencies.

bash
npm i -D enzyme @types/enzyme @wojtekmaj/enzyme-adapter-react-17
bash
npm i -D enzyme @types/enzyme @wojtekmaj/enzyme-adapter-react-17

Next, we'll create __tests__ folder in the root of the src directory. In there we add TicTacToe.enzyme.test.tsx for our Enzyme tests. We'll be using Jest to run the tests, which comes pre-installed with create-react-app, used for the Tic-Tac-Toe game.

Before writing the actual tests we need to do a bit of setup, namely, configure the Enzyme's adapter for React.

typescript
// TicTacToe.enzyme.test.tsx import { configure } from "enzyme"; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; configure({ adapter: new Adapter() });
typescript
// TicTacToe.enzyme.test.tsx import { configure } from "enzyme"; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; configure({ adapter: new Adapter() });

It's important to use the adapter of the same version as the version of React, as it provides the bridge to interact with React API.

Once the initial setup is complete, we can begin writing tests. The best structure for tests depends on the app and its functionality, but often a good first step is to verify that the default rendered content is as expected. Therefore, we will begin by testing that the game starts with the correct size grid after selecting the player. If you remember from the Tic-Tac-Toe tutorial, when the game starts, the player sees the Choose your player screen. We make our choice here by selecting X, and then verify that the grid is rendered with the correct number of squares.

tsx
// TicTacToe.enzyme.test import React from "react"; import { mount } from "enzyme"; import { configure } from "enzyme"; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import TicTacToe from "../TicTacToe"; import { PLAYER_O, PLAYER_X } from "../constants"; configure({ adapter: new Adapter() }); it("should render board with correct number of squares", () => { // Render the game component const wrapper = mount(<TicTacToe />); // Find the 'X' button to select X const buttonX = wrapper.findWhere( (component) => component.name() === "button" && component.text() === "X", ); // Press it buttonX.simulate("click"); // Check that board is rendered expect(wrapper.find("Square").length).toBe(9); });
tsx
// TicTacToe.enzyme.test import React from "react"; import { mount } from "enzyme"; import { configure } from "enzyme"; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import TicTacToe from "../TicTacToe"; import { PLAYER_O, PLAYER_X } from "../constants"; configure({ adapter: new Adapter() }); it("should render board with correct number of squares", () => { // Render the game component const wrapper = mount(<TicTacToe />); // Find the 'X' button to select X const buttonX = wrapper.findWhere( (component) => component.name() === "button" && component.text() === "X", ); // Press it buttonX.simulate("click"); // Check that board is rendered expect(wrapper.find("Square").length).toBe(9); });

Note that we aim to avoid testing implementation details and instead, test the component from the user's perspective, i.e., how the user would interact with it. This approach not only makes it easier to transition the tests to React Testing Library later but, more importantly, helps to decouple the testing from the component's internal logic and make the tests more resilient to changes. After all, is it necessary to check if the implementation of the move function has changed as long as the moves are still executed correctly in the UI?

Because Enzyme has a more unit-test-focused approach, testing components with it in the same way as you would with React Testing Library can be more challenging. Firstly, we need to use the findWhere method to locate the element with a specific text. Next, we must ensure that it's a button, so we don't capture any wrapper components. Finally, to access the Square components, we must first override their displayName method.

tsx
// TicTacToe.tsx const Square = styled.div``; Square.displayName = "Square";
tsx
// TicTacToe.tsx const Square = styled.div``; Square.displayName = "Square";

We could also query squares by a component reference however in that case we'd have to export the Square component and directly import it into the tests, which is not optimal.

Another thing to note is that we are using the mount method instead of shallow. It does a full DOM render of the component and its children, useful in case we need to investigate our styled components.

Now that we have a basic check for the default screen done, we can concentrate on testing user actions, namely that player's move is rendered correctly.

tsx
// TicTacToe.enzyme.test it("should register and display the result of human player's move", () => { // Render the game component const wrapper = mount(<TicTacToe />); const buttonX = wrapper.findWhere( (component) => component.name() === "button" && component.text() === "X", ); buttonX.simulate("click"); const firstSquare = wrapper.find("Square").at(0); // Click the first square firstSquare.simulate("click"); // Validate that it has 'X' rendered expect(firstSquare.text()).toBe("X"); });
tsx
// TicTacToe.enzyme.test it("should register and display the result of human player's move", () => { // Render the game component const wrapper = mount(<TicTacToe />); const buttonX = wrapper.findWhere( (component) => component.name() === "button" && component.text() === "X", ); buttonX.simulate("click"); const firstSquare = wrapper.find("Square").at(0); // Click the first square firstSquare.simulate("click"); // Validate that it has 'X' rendered expect(firstSquare.text()).toBe("X"); });

Since it's now possible to select components by their display name, getting a component at a specific index using the at selector is straightforward. We can then verify that its text content is correct by using the text() method.

At this point, we can make a slight modification by converting the method for finding a component with a specific text into a utility function.

ts
// TicTacToe.enzyme.test.tsx import { mount, ReactWrapper } from "enzyme"; // Helper function to get an element by its text const findByText = (wrapper: ReactWrapper, text: string, name = "button") => { return wrapper.findWhere( (component) => component.name() === name && component.text() === text, ); };
ts
// TicTacToe.enzyme.test.tsx import { mount, ReactWrapper } from "enzyme"; // Helper function to get an element by its text const findByText = (wrapper: ReactWrapper, text: string, name = "button") => { return wrapper.findWhere( (component) => component.name() === name && component.text() === text, ); };

Let's follow up by checking that player cannot make a move to the taken square. In the previous tutorial, we added the squares prop to the TicTacToe component, which we'll use now to fast-forward the game to a specific state.

tsx
// TicTacToe.enzyme.test it("should not make a move if the square is not empty", () => { const wrapper = mount( <TicTacToe squares={[PLAYER_X, null, PLAYER_O, null, null, null, null, null, null]} />, ); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Get a non-empty square const nonEmptySquare = wrapper.find("Square").at(2); // Click it nonEmptySquare.simulate("click"); // Check that text content stays the same expect(nonEmptySquare.text()).toBe("O"); });
tsx
// TicTacToe.enzyme.test it("should not make a move if the square is not empty", () => { const wrapper = mount( <TicTacToe squares={[PLAYER_X, null, PLAYER_O, null, null, null, null, null, null]} />, ); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Get a non-empty square const nonEmptySquare = wrapper.find("Square").at(2); // Click it nonEmptySquare.simulate("click"); // Check that text content stays the same expect(nonEmptySquare.text()).toBe("O"); });

Testing async actions with Enzyme

We have tested that the payer can make a move onto an empty square. However, we didn't test whether the AI's move that comes next is also correct. This will be a bit more challenging than testing the human move because the aiMove function runs inside the useEffect hook. The issue with that is that the code inside the hook will run on the next render, and its result will not be available until the component updates.

To help with this, the Enzyme wrapper object has an update() method that forces the component to re-render, so we can manually trigger the aiMove function after the human move. However, that's not enough in this case, as the aiMove runs inside setTimeout to make it feel more natural.

So, on top of forcing the wrapper to re-render, we also need to manually execute all pending timers. To do this, we first mock the timers in the tests with jest.useFakeTimers(), and then when needed, we manually execute them with jest.runAllTimers().

Additionally, this call will need to be wrapped in act from react-dom/test-utils. This is because in a React component when a re-render is triggered, it may schedule new timers or update existing ones. If jest.runAllTimers() is called before all updates are flushed, the timing of the timers may be incorrect, leading to unexpected test results or failures.

By wrapping jest.runAllTimers() in act(), React will batch and flush all updates to the component's state and props before executing the timers, ensuring that the test behaves as expected.

With this knowledge, we can test that AI makes a correct move after the human move is done.

tsx
// Mock the timers beforeEach(() => { jest.useFakeTimers(); }); it("should register and display the result of human player's move", async () => { // Render the game component const wrapper = mount(<TicTacToe />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); const firstSquare = wrapper.find("Square").at(0); // Click the first square firstSquare.simulate("click"); // Validate that it has 'X' rendered expect(firstSquare.text()).toBe("X"); // Trigger component update wrapper.update(); // Execute mock timers act(() => { jest.runAllTimers(); }); // Get the Square component with the value of 'O' const aiSquares = findByText(wrapper, "O", "Square"); // Verify that one such component is present expect(aiSquares.length).toEqual(1); });
tsx
// Mock the timers beforeEach(() => { jest.useFakeTimers(); }); it("should register and display the result of human player's move", async () => { // Render the game component const wrapper = mount(<TicTacToe />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); const firstSquare = wrapper.find("Square").at(0); // Click the first square firstSquare.simulate("click"); // Validate that it has 'X' rendered expect(firstSquare.text()).toBe("X"); // Trigger component update wrapper.update(); // Execute mock timers act(() => { jest.runAllTimers(); }); // Get the Square component with the value of 'O' const aiSquares = findByText(wrapper, "O", "Square"); // Verify that one such component is present expect(aiSquares.length).toEqual(1); });

A bit of jumping through some hoops, but we got the test working.

If you get stuck at some point wondering why the tests are failing when you're sure they shouldn't, Enzyme's wrapper has a handy debug() method that prints the rendered component as it would appear in the DOM. You can use it like this: console.log(wrapper.debug()).

Testing modals with Enzyme

We have two more things to test to make this test suite comprehensive:

  • When there's a winning combination or a draw, the modal with the result is shown.
  • Pressing the Start Over button starts a new game and shows the initial screen.

For the first scenario, we'll provide the grid state one move away from the endgame. Then, by making that move, we can test that the game is finished properly.

These tests are special in that the endgame result is announced by a modal component. Essentially, they boil down to verifying the modal's appearance and then checking its content. The tricky part here is that the modal's appearance or disappearance is controlled by the modalOpen state variable, which we set to true at the end of the game. Due to the way the React lifecycle works, the modal will be shown on the subsequent re-render of the component after we have set modalOpen to true. As a result, we need to manually trigger the re-render again in this case. This will sound familiar since we have earlier applied the same logic to testing the AI move.

Before testing, we need to make sure to add displayName to ModalContent in the ResultModal component.

tsx
// TicTacToe.enzyme.test it("should correctly show Player X as a winner", () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, null, PLAYER_O, PLAYER_O, null, PLAYER_X, null, PLAYER_O ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the winning move wrapper.find("Square").at(2).simulate("click"); // Wait for result modal to appear act(() => { jest.runAllTimers(); }); wrapper.update(); // Check that result is declared properly expect(wrapper.find("ModalContent").text()).toBe("Player X wins!"); }); it("should correctly display the draw result", () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, PLAYER_O, PLAYER_O, PLAYER_O, null, PLAYER_X, PLAYER_X, PLAYER_O ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the final move wrapper.find("Square").at(5).simulate("click"); // Wait for result modal to appear act(() => { jest.runAllTimers(); }); wrapper.update(); // Check that result is declared properly expect(wrapper.find("ModalContent").text()).toBe("It's a draw"); }); it("should correctly show Player O as a winner", () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, PLAYER_X, null, PLAYER_X, null ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the move wrapper.find("Square").at(6).simulate("click"); // Wait for the AI move act(() => { jest.runAllTimers(); }); wrapper.update(); // Check that result is declared properly expect(wrapper.find("ModalContent").text()).toBe("Player O wins!"); });
tsx
// TicTacToe.enzyme.test it("should correctly show Player X as a winner", () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, null, PLAYER_O, PLAYER_O, null, PLAYER_X, null, PLAYER_O ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the winning move wrapper.find("Square").at(2).simulate("click"); // Wait for result modal to appear act(() => { jest.runAllTimers(); }); wrapper.update(); // Check that result is declared properly expect(wrapper.find("ModalContent").text()).toBe("Player X wins!"); }); it("should correctly display the draw result", () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, PLAYER_O, PLAYER_O, PLAYER_O, null, PLAYER_X, PLAYER_X, PLAYER_O ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the final move wrapper.find("Square").at(5).simulate("click"); // Wait for result modal to appear act(() => { jest.runAllTimers(); }); wrapper.update(); // Check that result is declared properly expect(wrapper.find("ModalContent").text()).toBe("It's a draw"); }); it("should correctly show Player O as a winner", () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, PLAYER_X, null, PLAYER_X, null ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the move wrapper.find("Square").at(6).simulate("click"); // Wait for the AI move act(() => { jest.runAllTimers(); }); wrapper.update(); // Check that result is declared properly expect(wrapper.find("ModalContent").text()).toBe("Player O wins!"); });

For the sake of completeness, we're testing all three possible endgame scenarios. Note that the grid is formatted in the same way as the game's grid, making it easier to see the state of the game. If you're using Prettier for code formatting, you can disable it for this line with // prettier-ignore to maintain custom formatting.

Also worth noting that in the last test, we set up the board so that after the human player moves, both remaining options for the AI's move will result in a win. We don't have to explicitly wait for the AI's turn; instead, we wait for the modal to appear, which should happen after the last move.

As a final test, we confirm that the game resets after the Start over button is pressed.

tsx
// TicTacToe.enzyme.test.tsx it("should start a new game after 'Start over' button is pressed", () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, null, null, PLAYER_X, PLAYER_X ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the winning move wrapper.find("Square").at(6).simulate("click"); act(() => { jest.runAllTimers(); }); // Re-render component wrapper.update(); // Get restart button and click it const restartButton = findByText(wrapper, "Start over"); restartButton.simulate("click"); // Verify that new game screen is shown const choosePlayer = findByText(wrapper, "Choose your player", "p"); expect(choosePlayer.length).toBe(1); });
tsx
// TicTacToe.enzyme.test.tsx it("should start a new game after 'Start over' button is pressed", () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, null, null, PLAYER_X, PLAYER_X ]; const wrapper = mount(<TicTacToe squares={grid} />); const buttonX = findByText(wrapper, "X"); buttonX.simulate("click"); // Make the winning move wrapper.find("Square").at(6).simulate("click"); act(() => { jest.runAllTimers(); }); // Re-render component wrapper.update(); // Get restart button and click it const restartButton = findByText(wrapper, "Start over"); restartButton.simulate("click"); // Verify that new game screen is shown const choosePlayer = findByText(wrapper, "Choose your player", "p"); expect(choosePlayer.length).toBe(1); });

Now we have a comprehensive test suite in Enzyme for the TicTacToe component. While this may not necessarily be how many would approach testing such a component, it ensures that the tests will still be beneficial even after the internal implementation details of the component change.

It's not quite straightforward to test async actions with Enzyme, requiring us to use Jest's fake timers and manually trigger component re-renders. However, it is possible, albeit with some extra effort.

These tests will provide a solid foundation for demonstrating the differences with React Testing Library and serving as a migration code example.

Adding React Testing Library tests

React Testing Library is a popular testing library that has as its main principles testing components as users would use them, avoiding testing implementation details, and ensuring accessibility. By following these principles, testing with React Testing Library can lead to more robust and maintainable tests that closely mimic the user experience.

If you want to learn more about testing with React Testing Library and its best practices I have a more in-depth post about it: Improving React Testing Library Tests.

In this section, we'll see how these principles make it easy to test components without relying on implementation-specific details, which can help future-proof tests against changes to a codebase.

Now that we're using the latest version of React Testing Library we can update the React version to 18 and remove all the Enzyme-related dependencies at the same time. To be able to run the tests we will also exclude the Enzyme file in the package.json script.

json
"scripts": { "test": "react-scripts test --testPathIgnorePatterns=enzyme.test.ts" }
json
"scripts": { "test": "react-scripts test --testPathIgnorePatterns=enzyme.test.ts" }

Additionally, we'll need to install React Testing Library and all related packages.

bash
npm install --save-dev @testing-library/user-event @testing-library/user-event @testing-library/jest-dom
bash
npm install --save-dev @testing-library/user-event @testing-library/user-event @testing-library/jest-dom

@testing-library/jest-dom is a handy library that extends Jest's expect function with a set of matchers that are specific to React Testing Library. For example, instead of writing expect(screen.getAllByTestId(/square/).length).toEqual(9); we can do expect(screen.getAllByTestId(/square/)).toHaveLength(9).

@testing-library/user-event is useful for simulating user interactions and is recommended over RTL's own fireEvent method as it simulates them closer to the user interactions in the browser.

We will continue with the same approach and create TicTacToe.rtl.test.tsx. We'll introduce a basic setup and write the first test. But before that, we need to go back to TicTacToe.tsx and make a small modification, namely add data-testid for each square. Normally, we'd use the *ByRole query for the elements. However, in this case, the game squares are basic div elements without any meaningful ARIA role, so we'll use the *ByTestId query instead.

tsx
// TicTacToe.tsx // ... { grid.map((value, index) => { const isActive = value !== null; return ( <Square data-testid={`square_${index}`} // Add testid key={index} onClick={() => humanMove(index)} > {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); }); }
tsx
// TicTacToe.tsx // ... { grid.map((value, index) => { const isActive = value !== null; return ( <Square data-testid={`square_${index}`} // Add testid key={index} onClick={() => humanMove(index)} > {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); }); }

This data-testid is an HTML attribute used by React Testing Library to select elements in the DOM. It's a way to identify an element in the markup that is not tied to its visual presentation or implementation details. It is commonly used to test components that have dynamic behavior or that do not have an appropriate ARIA role to query by role.

We'll follow the same path as with Enzyme tests and verify that the game starts with the grid of the correct size after choosing a player.

tsx
import React from "react"; import { render, fireEvent, screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import userEvent from "@testing-library/user-event"; import TicTacToe from "../TicTacToe"; // setup userEvent function setup(jsx: React.ReactElement) { return { user: userEvent.setup(), ...render(jsx), }; } it("should render board with correct number of squares", async () => { // Render the game component const { user } = setup(<TicTacToe />); // Click 'X' to start the game as player X await user.click(screen.getByText("X")); // Check that the correct number of squares is rendered expect(screen.getAllByTestId(/square/)).toHaveLength(9); });
tsx
import React from "react"; import { render, fireEvent, screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import userEvent from "@testing-library/user-event"; import TicTacToe from "../TicTacToe"; // setup userEvent function setup(jsx: React.ReactElement) { return { user: userEvent.setup(), ...render(jsx), }; } it("should render board with correct number of squares", async () => { // Render the game component const { user } = setup(<TicTacToe />); // Click 'X' to start the game as player X await user.click(screen.getByText("X")); // Check that the correct number of squares is rendered expect(screen.getAllByTestId(/square/)).toHaveLength(9); });

First, we add a utility function called setup. This function will set up userEvent in one place for all the tests. Then, we render the component, start the game, and verify that all the squares are correctly rendered.

Starting from version 14, all user events are asynchronous. Therefore, we need to make all the tests that use userEvent asynchronous as well.

Note that we can also get items by partial match, using regex syntax - getAllByTestId(/square/) - returns all the items which include square in their testid attribute. The library has extensive documentation about the types of queries available.

Testing async actions with React Testing Library

Next, let's verify that when we click on an empty square, a move for that player is made. Additionally, we can test that AI makes its move next.

tsx
it("should register and display result of human player's move", async () => { const { user } = setup(<TicTacToe />); await user.click(screen.getByText("X")); // Click the first square await user.click(screen.getByTestId("square_0")); // Validate that it has 'X' rendered expect(screen.getByTestId("square_0")).toHaveTextContent("X"); // Check that we have O in the DOM expect(await screen.findByText("O")).toBeInTheDocument(); });
tsx
it("should register and display result of human player's move", async () => { const { user } = setup(<TicTacToe />); await user.click(screen.getByText("X")); // Click the first square await user.click(screen.getByTestId("square_0")); // Validate that it has 'X' rendered expect(screen.getByTestId("square_0")).toHaveTextContent("X"); // Check that we have O in the DOM expect(await screen.findByText("O")).toBeInTheDocument(); });

React Testing Library makes testing asynchronous actions much easier and more natural. When we use the findBy* query, which is asynchronous, it executes after the component's state is updated and the rendering cycles are finished. This means we don't need to manually trigger a component update or tinker with timers. Furthermore, the actions are internally wrapped in the act function, so we don't need to do that either.

If the element you are querying depends on the result of an asynchronous action (such as a component render, API call, or timeout), using the findBy* query is the way to test it. This approach also helps with the warning message An update to ... inside a test was not wrapped in act(...), which often appears when an asynchronous action has not been properly awaited.

Next, we are going to test that when the player clicks on a non-empty square that move won't have any effect.

tsx
// TicTacToe.rtl.test it("should not make a move if the square is not empty", async () => { const { user } = setup( <TicTacToe squares={[PLAYER_X, null, PLAYER_O, null, null, null, null, null, null]} />, ); await user.click(screen.getByText("X")); // Click a non-empty square await user.click(screen.getByTestId("square_2")); expect(screen.getByTestId("square_2")).toHaveTextContent("O"); });
tsx
// TicTacToe.rtl.test it("should not make a move if the square is not empty", async () => { const { user } = setup( <TicTacToe squares={[PLAYER_X, null, PLAYER_O, null, null, null, null, null, null]} />, ); await user.click(screen.getByText("X")); // Click a non-empty square await user.click(screen.getByTestId("square_2")); expect(screen.getByTestId("square_2")).toHaveTextContent("O"); });

Testing modals with React Testing Library

Now we can test that all the endgame combinations are handled correctly.

tsx
// TicTacToe.rtl.test it("should correctly show Player X as a winner", async () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, null, PLAYER_O, PLAYER_O, null, PLAYER_X, null, PLAYER_O ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the winning move await user.click(screen.getByTestId("square_2")); // Check that result is declared properly expect(await screen.findByText("Player X wins!")).toBeInTheDocument(); }); it("should correctly display the draw result", async () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, PLAYER_O, PLAYER_O, PLAYER_O, null, PLAYER_X, PLAYER_X, PLAYER_O ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the final move await user.click(screen.getByTestId("square_5")); // Check that result is declared properly expect(await screen.findByText("It's a draw")).toBeInTheDocument(); }); it("should correctly show Player O as a winner", async () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, PLAYER_X, null, PLAYER_X, null ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the move await user.click(screen.getByTestId("square_6")); // Check that result is declared properly expect(await screen.findByText("Player O wins!")).toBeInTheDocument(); });
tsx
// TicTacToe.rtl.test it("should correctly show Player X as a winner", async () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, null, PLAYER_O, PLAYER_O, null, PLAYER_X, null, PLAYER_O ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the winning move await user.click(screen.getByTestId("square_2")); // Check that result is declared properly expect(await screen.findByText("Player X wins!")).toBeInTheDocument(); }); it("should correctly display the draw result", async () => { // prettier-ignore const grid = [ PLAYER_X, PLAYER_X, PLAYER_O, PLAYER_O, PLAYER_O, null, PLAYER_X, PLAYER_X, PLAYER_O ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the final move await user.click(screen.getByTestId("square_5")); // Check that result is declared properly expect(await screen.findByText("It's a draw")).toBeInTheDocument(); }); it("should correctly show Player O as a winner", async () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, PLAYER_X, null, PLAYER_X, null ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the move await user.click(screen.getByTestId("square_6")); // Check that result is declared properly expect(await screen.findByText("Player O wins!")).toBeInTheDocument(); });

From this test, we can see that React Testing Library handles testing modals that appear on state changes pretty well too.

The final test is to assert that the game restarts correctly.

tsx
// TicTacToe.rtl.test it("should start a new game after 'Start over' button is pressed", async () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, null, null, PLAYER_X, PLAYER_X ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the winning move await user.click(screen.getByTestId("square_6")); await user.click(await screen.findByText("Start over")); expect(await screen.findByText("Choose your player")).toBeInTheDocument(); });
tsx
// TicTacToe.rtl.test it("should start a new game after 'Start over' button is pressed", async () => { // prettier-ignore const grid = [ PLAYER_O, null, PLAYER_O, PLAYER_X, PLAYER_O, null, null, PLAYER_X, PLAYER_X ]; const { user } = setup(<TicTacToe squares={grid} />); await user.click(screen.getByText("X")); // Make the winning move await user.click(screen.getByTestId("square_6")); await user.click(await screen.findByText("Start over")); expect(await screen.findByText("Choose your player")).toBeInTheDocument(); });

With that done, we have a nice comprehensive test suite where we used React Testing Library and tested the game in the same way as the end user would interact with it.

Although we saw how powerful React Testing Library is, due to the nature of the game we tested, we didn't get a chance to take advantage of the accessibility-related aspects of the library, such as using ByRole* queries. These benefits can be better seen in examples of testing forms, which I have written about in a detailed article: Testing React Hook Form With React Testing Library.

Conclusion

In this tutorial, we have highlighted the main differences between Enzyme and React Testing Library and provided a migration guide for those looking to switch from the former to the latter. The post emphasizes the key difference between the libraries: React Testing Library is designed to test a component's behavior as a user would interact with it, whereas Enzyme focuses more on testing implementation details.

React Testing Library encourages developers to test their components based on their output, which makes for more reliable tests that are less likely to break when implementation details change. Additionally, React Testing Library provides a simpler and more natural API, which can save developers time when writing tests.

Overall, the specifics of the transition will depend on each developer's use case. However, hopefully, this article makes a compelling case for why everyone should consider migrating from Enzyme to React Testing Library and provides useful guidance for making the transition smoother.

References and Resources