Thomas Lombart

The Complete Beginner's Guide to Testing React Apps (Updated for 2020)

react, testOct 09, 2019

Updated on 28 Aug. 2020

Building web applications is not an easy task, as of today. To do so, you’re probably using something like React, Vue, or Angular. Your app is faster, the code is both more maintainable and readable. But that’s not enough. The more your codebase grows, the more complex and buggy it is. So if you care about that, learn to write tests. That’s what we’ll do today for React apps.

Luckily for you, there are already testing solutions for React, especially one: react-testing-library made by Kent C. Dodds. So, let’s discover it, shall we?

Why React Testing Library

Basically, React Testing Library (RTL) is made of simple and complete React DOM testing utilities that encourage good testing practices, especially one:

The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds

In fact, developers tend to test what we call implementation details. Let’s take a simple example to explain it. We want to create a counter that we can both increment and decrement. Here is the implementation (with a class component) with two tests: the first one is written with Enzyme and the other one with React Testing Library.

counter.js
import React from "react"
class Counter extends React.Component {
state = { count: 0 }
increment = () => this.setState(({ count }) => ({ count: count + 1 }))
decrement = () => this.setState(({ count }) => ({ count: count - 1 }))
render() {
return (
<div>
<button onClick={this.decrement}>-</button>
<p>{this.state.count}</p>
<button onClick={this.increment}>+</button>
</div>
)
}
}
export default Counter
counter-enzyme.test.js
import React from "react"
import { shallow } from "enzyme"
import Counter from "./counter"
describe("<Counter />", () => {
it("properly increments and decrements the counter", () => {
const wrapper = shallow(<Counter />)
expect(wrapper.state("count")).toBe(0)
wrapper.instance().increment()
expect(wrapper.state("count")).toBe(1)
wrapper.instance().decrement()
expect(wrapper.state("count")).toBe(0)
})
})
counter-rtl.test.js
import React from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import Counter from "./counter"
describe("<Counter />", () => {
it("properly increments and decrements the counter", () => {
render(<Counter />)
const counter = screen.getByText("0")
const incrementButton = screen.getByText("+")
const decrementButton = screen.getByText("-")
fireEvent.click(incrementButton)
expect(counter.textContent).toEqual("1")
fireEvent.click(decrementButton)
expect(counter.textContent).toEqual("0")
})
})

Note: Don’t worry if you don’t fully understand the test files, we’ll see all of this afterward 😉

Can you guess which test file is the best one and why? If you’re not used to tests, you may think that both are fine. In fact, the two tests make sure that the counter is incremented and decremented. However, the first one is testing implementation details and it has two risks:

  • false-positive: the test passes even if the code is broken.
  • false-negative: the test is broken even if the code is right.

False-positive

Let’s say we want to refactor our components because we want to make it possible to set any count value. So we remove our increment and decrement methods and then add a new setCount method. We forgot to wire this new method to our different buttons:

counter.js
import React from "react"
class Counter extends React.Component {
state = { count: 0 }
setCount = (count) => this.setState({ count })
render() {
return (
<div>
<button onClick={this.decrement}>-</button>
<p>{this.state.count}</p>
<button onClick={this.increment}>+</button>
</div>
)
}
}
export default Counter

The first test (Enzyme) will pass, but the second one (RTL) will fail. Indeed, the first one doesn’t care if our buttons are correctly wired to the methods. It just looks at the implementation itself: our increment and decrement method. This is a false positive.

False-negative

Now, what if we wanted to refactor our class component to hooks? We would change its implementation:

counter.js
import React, { useState } from "react"
const Counter = () => {
const [count, setCount] = useState(0)
const increment = () => setCount((count) => count + 1)
const decrement = () => setCount((count) => count - 1)
return (
<div>
<button onClick={decrement}>-</button>
<p>{count}</p>
<button onClick={increment}>+</button>
</div>
)
}
export default Counter

This time, the first test is going to be broken even if your counter still works. This is a false-negative! Enzyme will complain about state not being able to work on functional components:

ShallowWrapper::state() can only be called on class components

Then we have to change the test:

import React from "react"
import { shallow } from "enzyme"
import Counter from "./counter"
describe("<Counter />", () => {
it("properly increments and decrements the counter", () => {
const setValue = jest.fn()
const useStateSpy = jest.spyOn(React, "useState")
useStateSpy.mockImplementation((initialValue) => [initialValue, setValue])
const wrapper = shallow(<Counter />)
wrapper.find("button").last().props().onClick()
expect(setValue).toHaveBeenCalledWith(1)
// We can't make any assumptions here on the real count displayed
// In fact, the setCount setter is mocked!
wrapper.find("button").first().props().onClick()
expect(setValue).toHaveBeenCalledWith(-1)
})
})

To be honest, I’m not even sure if this is the right way to test it with Enzyme when it comes to hooks. In fact, we can’t even make assumptions on the displayed count because of the mocked setter.

However, the test without implementation details works as expected in all cases! So, if we had something to retain so far, it would be to avoid testing implementation details.

Note: I’m not saying Enzyme is bad. I’m just saying testing implementation details will make tests harder to maintain and unreliable. In this article, we are going to use React Testing Library because it encourages testing best practices.

A simple test step-by-step

Maybe there is still an air of mystery around the test written with React Testing Library. As a reminder, here it is:

import React from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import Counter from "./counter"
describe("<Counter />", () => {
it("properly increments and decrements the counter", () => {
render(<Counter />)
const counter = screen.getByText("0")
const incrementButton = screen.getByText("+")
const decrementButton = screen.getByText("-")
fireEvent.click(incrementButton)
expect(counter.textContent).toEqual("1")
fireEvent.click(decrementButton)
expect(counter.textContent).toEqual("0")
})
})

Let’s decompose it to understand how they’re made of. Introducing the AAA pattern: Arrange, Act, Assert.

import React from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import Counter from "./counter"
describe("<Counter />", () => {
it("properly increments and decrements the counter", () => {
// Arrange
render(<Counter />)
const counter = screen.getByText("0")
const incrementButton = screen.getByText("+")
const decrementButton = screen.getByText("-")
// Act
fireEvent.click(incrementButton)
// Assert
expect(counter.textContent).toEqual("1")
// Act
fireEvent.click(decrementButton)
// Assert
expect(counter.textContent).toEqual("0")
})
})

Almost of your tests will be written that way:

  1. You arrange (= setup) your code so that everything is ready for the next steps.
  2. You act, you perform the steps a user is supposed to do (such as a click).
  3. You make assertions on what is supposed to happen.

Arrange

In our test, we’ve done two tasks in the arrange part:

  1. Render the component
  2. Getting the different elements of the DOM needed using queries and screen

Render

We can render our component with the render method, which is part of RTL’s API:

function render(
ui: React.ReactElement,
options?: Omit<RenderOptions, 'queries'>
): RenderResult

Where ui is the component to mount. We can provide some options to render, but they are not often needed so, I’ll let you check out what’s possible in the docs.

Basically, all this function does is that it renders your component using ReactDOM.render (or hydrate for server-side rendering) in a newly created div appended directly to document.body. You won’t often need (at least in the beginning) the result from the render method, so I’ll let you check the docs as well.

Queries and screen

Once our component is rendered correctly, we can get the different elements of the DOM using screen queries.

But what is screen? As said above, the component is rendered in document.body. Since it’s common to query it, Testing Library exports an object with every query pre-bound to document.body. Note that we can also destructure queries from the render result but trust me, it’s more convenient to use screen.

And now, you may think: “what are these queries”? They are utilities that allow you to query the DOM like a user would do it. Thus, you can find elements by label text, by a placeholder, by title.

Here are some queries examples taken from the docs:

  • getByLabelText: searches for the label that matches the given text passed as an argument and then finds the element associated with that label.
  • getByText: search for all elements with a text node with textContent matching the given text passed as an argument.
  • getByTitle: returns the element with a title attribute matching the given text passed as an argument.
  • getByPlaceholderText: searches for all elements with a placeholder attribute and find one that matches the given text passed as an argument.

There are many variants to a particular query:

  • getBy: returns the first matching node for a query, throw an error if no elements match, or find more than one match.
  • getAllBy: returns an array of all matching nodes for a query, and throw an error if no elements match.
  • queryBy: returns the first matching node for a query, and return null if no elements match. This is useful for asserting an element that is not present.
  • queryAllBy: returns an array of all matching nodes for a query, and return an empty array ([]) if no elements match.
  • findBy: return a promise, which resolves when an element is found which matches the given query.
  • findAllBy: return a promise, which resolves to an array of elements when any elements are found which match the given query.

Using the right query at the right time can be challenging. I highly recommend that you check Testing Playground to better know which queries to use in your apps.

Let’s come back to our example:

render(<Counter />)
const counter = screen.getByText("0")
const incrementButton = screen.getByText("+")
const decrementButton = screen.getByText("-")

In this example, we can see that we first render the <Counter/>. The base element of this component will look like the following:

<body>
<div>
<Counter />
</div>
</body>

Then, thanks to screen.getByText, we can query from document.body the increment button from, the decrement button and the counter. Hence, we will get for each button an instance of HTMLButtonElement and for the counter an instance of HTMLParagraphElement.

Act

Now that everything is set up, we can act. For that, we use fireEvent from DOM Testing Library:

fireEvent((node: HTMLElement), (event: Event))

Simply put, this function takes a DOM node (that you can query with the queries seen above!) and fires DOM events such as click, focus, change, etc. There are many other events you can dispatch that you can find by reading DOM Testing Library source code.

Our example is relatively simple as we just want to click a button, so we simply do:

fireEvent.click(incrementButton)
// OR
fireEvent.click(decrementButton)

Assert

Here comes the last part. Firing an event usually triggers some changes in your app. So we must do some assertions to make sure these changes happened. In our test, a good way to do so is to make sure the count rendered to the user has changed. Thus, we just have to assert the textContent property of counter is incremented or decrement:

expect(counter.textContent).toEqual("1")
expect(counter.textContent).toEqual("0")

And tadaaa! We successfully wrote a test that doesn’t test implementation details. 🥳

Test a to-do app

Let’s go deeper into this part by testing a more complex example. The app we’re going to test is a simple to-do app whose features are the following:

  • Add a new to-do
  • Mark a to-do as completed or active
  • Remove a to-do
  • Filter the to-dos: all, active and done to-dos

Yes, I know, you may be sick of to-do apps in every tutorial, but hey, they’re great examples!

Here is the code:

Todos.js
import React from "react"
function Todos({ todos: originalTodos }) {
const filters = ["all", "active", "done"]
const [input, setInput] = React.useState("")
const [todos, setTodos] = React.useState(originalTodos || [])
const [activeFilter, setActiveFilter] = React.useState(filters[0])
const addTodo = (e) => {
if (e.key === "Enter" && input.length > 0) {
setTodos((todos) => [{ name: input, done: false }, ...todos])
setInput("")
}
}
const filteredTodos = React.useMemo(
() =>
todos.filter((todo) => {
if (activeFilter === "all") {
return todo
}
if (activeFilter === "active") {
return !todo.done
}
return todo.done
}),
[todos, activeFilter]
)
const toggle = (index) => {
setTodos((todos) =>
todos.map((todo, i) =>
index === i ? { ...todo, done: !todo.done } : todo
)
)
}
const remove = (index) => {
setTodos((todos) => todos.filter((todo, i) => i !== index))
}
return (
<div>
<h2 className="title">To-dos</h2>
<input
className="input"
onChange={(e) => setInput(e.target.value)}
onKeyDown={addTodo}
value={input}
placeholder="Add something..."
/>
<ul className="list-todo">
{filteredTodos.length > 0 ? (
filteredTodos.map(({ name, done }, i) => (
<li key={`${name}-${i}`} className="todo-item">
<input
type="checkbox"
checked={done}
onChange={() => toggle(i)}
id={`todo-${i}`}
/>
<div className="todo-infos">
<label
htmlFor={`todo-${i}`}
className={`todo-name ${done ? "todo-name-done" : ""}`}
>
{name}
</label>
<button className="todo-delete" onClick={() => remove(i)}>
Remove
</button>
</div>
</li>
))
) : (
<p className="no-results">No to-dos!</p>
)}
</ul>
<ul className="list-filters">
{filters.map((filter) => (
<li
key={filter}
className={`filter ${
activeFilter === filter ? "filter-active" : ""
}`}
onClick={() => setActiveFilter(filter)}
>
{filter}
</li>
))}
</ul>
</div>
)
}
export default Todos

More on fireEvent

We saw previously how fireEvent allows us to click on a button queried with RTL queries (such as getByText). Let’s see how to use other events.

In this app, we can add a new to-do by writing something in the input and pressing the Enter key. We’ll need to dispatch two events:

  • change to add a text in the input
  • keyDown to press the enter key.

Let’s write the first part of the test:

test("adds a new to-do", () => {
render(<Todos />)
const input = screen.getByPlaceholderText(/add something/i)
const todo = "Read Master React Testing"
screen.getByText("No to-dos!")
fireEvent.change(input, { target: { value: todo } })
fireEvent.keyDown(input, { key: "Enter" })
})

In this code, we:

  1. Query the input by its placeholder.
  2. Declare the to-do we’re going to add
  3. Assert there were no to-dos using getByText (if No to-dos! was not in the app, getByText would throw an error)
  4. Add the to-do in the input
  5. Press the enter key.

One thing that may surprises you is the second argument we pass to fireEvent. Maybe you would expect it to be a single string instead of an object with a target property.

Well, under the hood, fireEvent dispatches an event to mimic what happens in a real app (it makes use of the dispatchEvent method). Thus, we need to dispatch the event as it would happen in our app, including setting the target property. The same logic goes for the keyDown event and the key property.

What should happen if we add a new to-do?

  • There should be a new item in the list
  • The input should be empty

Hence, we need to query somehow the new item in the DOM and make sure the value property of the input is empty:

screen.getByText(todo)
expect(input.value).toBe("")

The full test becomes:

test("adds a new to-do", () => {
render(<Todos />)
const input = screen.getByPlaceholderText(/add something/i)
const todo = "Read Master React Testing"
screen.getByText("No to-dos!")
fireEvent.change(input, { target: { value: todo } })
fireEvent.keyDown(input, { key: "Enter" })
screen.getByText(todo)
expect(input.value).toBe("")
})

Better assertions with jest-dom

The more you’ll write tests with RTL, the more you’ll have to write assertions for your different DOM nodes. Writing such assertions can sometimes be repetitive and a bit hard to read. For that, you can install another Testing Library tool called jest-dom.

jest-dom provides a set of custom jest matchers that you can use to extend jest. These will make your tests more declarative, clear to read and to maintain.

There are many matchers you can use such as:

You can install it with the following command:

npm install --save-dev @testing-library/jest-dom

Then, you have to import the package once to extend the Jest matchers:

setupTest.js
import "@testing-library/jest-dom/extend-expect"

Note: I recommend that you do that in src/setupTests.js if you use Create React App. If you don’t use CRA, import it in one of the files defined in the setupFilesAfterEnv key of your Jest config.

Let’s come back to our test. By installing jest-dom, your assertion would become:

expect(input).toHaveValue("")

It’s not much, but it’s more readable, convenient and it improves the developer experience! 🙌

💡 If you want to see more test examples on this to-do app, I created a repo that contains all the examples of this article!

Asynchronous tests

I agree the counter and the to-do app are contrived examples. In fact, most real-world applications involve asynchronous actions: data fetching, lazy-loaded components, etc. Thus, you need to handle them in your tests.

Luckily for us, RTL gives us asynchronous utilities such as waitFor or waitForElementToBeRemoved.

In this part, we will use a straightforward posts app whose features are the following:

  • Create a post
  • See the newly created post in a list of posts
  • See an error if something has gone wrong while creating the post.

Here is the code:

Posts.js
import React from "react"
import { addPost } from "./api"
function Posts() {
const [posts, addLocalPost] = React.useReducer((s, a) => [...s, a], [])
const [formData, setFormData] = React.useReducer((s, a) => ({ ...s, ...a }), {
title: "",
content: "",
})
const [isPosting, setIsPosting] = React.useState(false)
const [error, setError] = React.useState("")
const post = async (e) => {
e.preventDefault()
setError("")
if (!formData.title || !formData.content) {
return setError("Title and content are required.")
}
try {
setIsPosting(true)
const {
status,
data: { id, ...rest },
} = await addPost(formData)
if (status === 200) {
addLocalPost({ id, ...rest })
}
setIsPosting(false)
} catch (error) {
setError(error.data)
setIsPosting(false)
}
}
return (
<div>
<form className="form" onSubmit={post}>
<h2>Say something</h2>
{error && <p className="error">{error}</p>}
<input
type="text"
placeholder="Your title"
onChange={(e) => setFormData({ title: e.target.value })}
/>
<textarea
type="text"
placeholder="Your post"
onChange={(e) => setFormData({ content: e.target.value })}
rows={5}
/>
<button className="btn" type="submit" disabled={isPosting}>
Post{isPosting ? "ing..." : ""}
</button>
</form>
<div>
{posts.map((post) => (
<div className="post" key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</div>
))}
</div>
</div>
)
}
export default Posts
api.js
let nextId = 0
export const addPost = (post) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.1) {
resolve({ status: 200, data: { ...post, id: nextId++ } })
} else {
reject({
status: 500,
data: "Something wrong happened. Please, retry.",
})
}
}, 500)
})
}

Let’s test the post creation feature. To do so, we need to:

  1. Mock the API to make sure a post creation doesn’t fail
  2. Fill in the tile
  3. Fill in the content of the post
  4. Click the Post button

Let’s first query the corresponding elements:

import React from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import { addPost as addPostMock } from "./api"
import Posts from "./Posts"
jest.mock("./api")
describe("Posts", () => {
test("adds a post", async () => {
addPostMock.mockImplementation((post) =>
Promise.resolve({ status: 200, data: { ...post, id: 1 } })
)
render(<Posts />)
const title = screen.getByPlaceholderText(/title/i)
const content = screen.getByPlaceholderText(/post/i)
const button = screen.getByText(/post/i)
const postTitle = "This is a post"
const postContent = "This is the content of my post"
})
})

You can see I’ve used queries differently this time. Indeed, when you pass a string to a getBy query, it expects to match exactly that string. If there’s something wrong with one character, then the query fails.

However, the queries also accept a regular expression as an argument. It can be handy if you want to quickly query a long text or if you want to query a substring of your sentence in case you’re still not sure of the wording.

For example, I know the placeholder of my content should include the word “post”. But, maybe the placeholder will see its wording change at some point and I don’t want my tests to break because of this simple change. So I use:

const content = screen.getByPlaceholderText(/post/i)

Note: for the same reason, I use i to make the search case-insensitive. That way, my test doesn’t fail if the case changes. Caution though! If the wording is important and shouldn’t change, don’t make use of regular expressions.

Then, we have to fire the corresponding events and make sure the post has been added. Let’s try it out:

test("adds a post", () => {
addPostMock.mockImplementation((post) =>
Promise.resolve({ status: 200, data: { ...post, id: 1 } })
)
render(<Posts />)
const title = screen.getByPlaceholderText(/title/i)
const content = screen.getByPlaceholderText(/post/i)
const button = screen.getByText(/post/i)
const postTitle = "This is a post"
const postContent = "This is the content of my post"
fireEvent.change(title, { target: { value: postTitle } })
fireEvent.change(content, { target: { value: postContent } })
fireEvent.click(button)
// Oops, this will fail ❌
expect(screen.queryByText(postTitle)).toBeInTheDocument()
expect(screen.queryByText(postContent)).toBeInTheDocument()
})

If we had run this test, it wouldn’t work! In fact, RTL can’t query our post title. But why? To answer that question, I’ll have to introduce you to one of your next best friends: debug.

Debugging tests

Simply put, debug is a utility function attached to the screen object that prints out a representation of your component’s associated DOM. Let’s use it:

test("adds a post", () => {
// ...
fireEvent.change(title, { target: { value: postTitle } })
fireEvent.change(content, { target: { value: postContent } })
fireEvent.click(button)
debug()
expect(screen.queryByText(postTitle)).toBeInTheDocument()
expect(screen.queryByText(postContent)).toBeInTheDocument()
})

In our case, debug outputs something similar to this:

<body>
<div>
<div>
<form class="form">
<h2>Say something</h2>
<input placeholder="Your title" type="text" />
<textarea placeholder="Your post" rows="5" type="text" />
<button class="btn" disabled="" type="submit">Post ing...</button>
</form>
<div />
</div>
</div>
</body>

Now that we know what your DOM looks like, we can guess what’s happening. The post hasn’t been added. If we closely pay attention, we can see the button’s text is now Posting instead of Post.

Do you know why? Because posting a post is asynchronous and we’re trying to execute the tests without waiting for the asynchronous actions. We’re just in the Loading phase. We can only make sure some stuff is going on:

test("adds a post", () => {
// ...
fireEvent.change(title, { target: { value: postTitle } })
fireEvent.change(content, { target: { value: postContent } })
fireEvent.click(button)
expect(button).toHaveTextContent("Posting")
expect(button).toBeDisabled()
})

Wait for changes

We can do something about that. More precisely, RTL can do something about that with asynchronous utilities such as waitFor:

function waitFor<T>(
callback: () => void,
options?: {
container?: HTMLElement
timeout?: number
interval?: number
onTimeout?: (error: Error) => Error
mutationObserverOptions?: MutationObserverInit
}
): Promise<T>

Simply put, waitFor takes a callback which contains expectations and waits for a specific time until these expectations pass.

By default this time is at most 1000ms at an interval of 50ms (the first function call is fired immediately). This callback is also run every time a child is added or removed in your component’s container using MutationObserver.

We’re going to make use of that function and put our initial assertions in it. The test now becomes:

import React from "react"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
// ...
describe("Posts", () => {
test("adds a post", async () => {
// ...
expect(button).toHaveTextContent("Posting")
expect(button).toBeDisabled()
await waitFor(() => {
screen.getByText(postTitle)
screen.getByText(postContent)
})
})
})

If you’re using CRA, maybe you encountered the following error:

TypeError: MutationObserver is not a constructor

That’s normal. DOM Testing Library v7 removed a shim of MutationObserver as it’s now widely supported. However, CRA, as the time of writing, still uses an older version of Jest (24 or before) which itself uses a JSDOM environment where MutationObserver doesn’t exist.

Two steps to fix it. First, install jest-environment-jsdom-sixteen as a dev dependency. Then, update your test script in your package.json file:

"scripts": {
...
"test": "react-scripts test --env=jest-environment-jsdom-sixteen"
...
}

Now, it passes! 🎉

There is also another way of testing asynchronous things with findBy* queries which is just a combination of getBy* queries and waitFor:

import React from "react"
import { fireEvent, render, screen } from "@testing-library/react"
// ...
describe("Posts", () => {
test("adds a post", async () => {
// ...
expect(button).toHaveTextContent("Posting")
expect(button).toBeDisabled()
await screen.findByText(postTitle)
screen.getByText(postContent)
})
})

Note: In the past, you could also use wait and waitForElement but they’re deprecated now. Don’t worry if you find them in certain tests!

We know for sure that the API successfully returned the full post after the await statement, so we don’t have to put async stuff after.

And remember, findByText is asynchronous! If you find yourself forgetting the await statement a little bit too much, I encourage you to install the following plugin: eslint-plugin-testing-library, it contains a rule that prevent you to do so! 😉


Pheeeew! That part was not easy.

Hopefully, these three examples allowed you to have an in-depth look at how you can start to write tests for your React apps, but that’s just the tip of the iceberg! A complex app often makes use of react-router, redux, React’s Context, third-party libraries (react-select for example). Kent C. Dodds has a complete course on that (and much more) called Testing JavaScript that I really recommend!

© 2020 Thomas Lombart