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! 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:
- You arrange (= setup) your code so that everything is ready for the next steps.
- You act, you perform the steps a user is supposed to do (such as a click).
- You make assertions on what is supposed to happen.
Arrange
In our test, we've done two tasks in the arrange part:
- Render the component
- 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 DOM elements 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 atitle
attribute matching the given text passed as an argument. -
getByPlaceholderText
: searches for all elements with aplaceholder
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, throws an error if no elements match, or finds more than one match. -
getAllBy
: returns an array of all matching nodes for a query and throws an error if no elements match. -
queryBy
: returns the first matching node for a query and returns 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 returns 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. You can dispatch many other events 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 to-do.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:
- Query the input by its placeholder.
- Declare the to-do we're going to add
- Assert no to-dos were using
getByText
(ifNo to-dos!
was not in the app,getByText
would throw an error) - Add the to-do in the input
- Press the enter key.
One thing that may surprise 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:
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:
- Mock the API to make sure a post creation doesn't fail
- Fill in the tile
- Fill in the content of the post
- 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 that 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, at the time of writing, CRA still uses an older version of Jest (24 or before), which 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 forget 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 from doing 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 uses 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!
Top comments (3)
High-quality stuff right there!
I like what Kent does!
Very well explained. Thank you @thomas for sharing.