As you begin to make use of React hooks in your applications, you’ll want to be certain the code you write is nothing short of solid. There’s nothing like shipping buggy code. One way to be certain your code is bug-free is to write tests. And testing React hooks is not much different from how React applications are tested in general.
In this tutorial, we will look at how to do that by making use of a to-do application built with hooks. We’ll cover writing of tests using Ezyme and React Testing Library, both of which are able to do just that. If you’re new to Enzyme, we actually posted about it a little while back showing how it can be used with Jest in React applications. It’s not a bad idea to check that as we dig into testing React hooks.
Here’s what we want to test
A pretty standard to-do component looks something like this:
import React, { useState, useRef } from "react";
const Todo = () => {
const [todos, setTodos] = useState([
{ id: 1, item: "Fix bugs" },
{ id: 2, item: "Take out the trash" }
]);
const todoRef = useRef();
const removeTodo = id => {
setTodos(todos.filter(todo => todo.id !== id));
};
const addTodo = data => {
let id = todos.length + 1;
setTodos([
...todos,
{
id,
item: data
}
]);
};
const handleNewTodo = e => {
e.preventDefault();
const item = todoRef.current;
addTodo(item.value);
item.value = "";
};
return (
<div className="container">
<div className="row">
<div className="col-md-6">
<h2>Add Todo</h2>
</div>
</div>
<form>
<div className="row">
<div className="col-md-6">
<input
type="text"
autoFocus
ref={todoRef}
placeholder="Enter a task"
className="form-control"
data-testid="input"
/>
</div>
</div>
<div className="row">
<div className="col-md-6">
<button
type="submit"
onClick={handleNewTodo}
className="btn btn-primary"
>
Add Task
</button>
</div>
</div>
</form>
<div className="row todo-list">
<div className="col-md-6">
<h3>Lists</h3>
{!todos.length ? (
<div className="no-task">No task!</div>
) : (
<ul data-testid="todos">
{todos.map(todo => {
return (
<li key={todo.id}>
<div>
<span>{todo.item}</span>
<button
className="btn btn-danger"
data-testid="delete-button"
onClick={() => removeTodo(todo.id)}
>
X
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
);
};
export default Todo;
Testing with Enzyme
We need to install the packages before we can start testing. Time to fire up the terminal!
npm install --save-dev enzyme enzyme-adapter-16
Inside the src
directory, create a file called setupTests.js. This is what we’ll use to configure Enzyme’s adapter.
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
Enzyme.configure({ adapter: new Adapter() });
Now we can start writing our tests! We want to test four things:
- That the component renders
- That the initial to-dos get displayed when it renders
- That we can create a new to-do and get back three others
- That we can delete one of the initial to-dos and have only one to-do left
In your src
directory, create a folder called __tests__
and create the file where you’ll write your Todo component’s tests in it. Let’s call that file Todo.test.js.
With that done, we can import the packages we need and create a describe
block where we’ll fill in our tests.
import React from "react";
import { shallow, mount } from "enzyme";
import Todo from "../Todo";
describe("Todo", () => {
// Tests will go here using `it` blocks
});
Test 1: The component renders
For this, we’ll make use of shallow render. Shallow rendering allows us to check if the render method of the component gets called — that’s what we want to confirm here because that’s the proof we need that the component renders.
it("renders", () => {
shallow(<Todo />);
});
Test 2: Initial to-dos get displayed
Here is where we’ll make use of the mount
method, which allows us to go deeper than what shallow
gives us. That way, we can check the length of the to-do items.
it("displays initial to-dos", () => {
const wrapper = mount(<Todo />);
expect(wrapper.find("li")).toHaveLength(2);
});
Test 3: We can create a new to-do and get back three others
Let’s think about the process involved in creating a new to-do:
- The user enters a value into the input field.
- The user clicks the submit button.
- We get a total of three to-do items, where the third is the newly created one.
it("adds a new item", () => {
const wrapper = mount(<Todo />);
wrapper.find("input").instance().value = "Fix failing test";
expect(wrapper.find("input").instance().value).toEqual("Fix failing test");
wrapper.find('[type="submit"]').simulate("click");
expect(wrapper.find("li")).toHaveLength(3);
expect(
wrapper
.find("li div span")
.last()
.text()
).toEqual("Fix failing test");
});
We mount the component then we make use of find()
and instance()
methods to set the value of the input field. We assert that the value of the input field is set to “Fix failing test” before going further to simulate a click event, which should add the new item to the to-do list.
We finally assert that we have three items on the list and that the third item is equal to the one we created.
Test 4: We can delete one of the initial to-dos and have only one to-do left
it("removes an item", () => {
const wrapper = mount(<Todo />);
wrapper
.find("li button")
.first()
.simulate("click");
expect(wrapper.find("li")).toHaveLength(1);
expect(wrapper.find("li span").map(item => item.text())).toEqual([
"Take out the trash"
]);
});
In this scenario, we return the to-do with a simulated click event on the first item. It’s expected that this will call the removeTodo()
method, which should delete the item that was clicked. Then we’re checking the numbers of items we have, and the value of the one that gets returned.
The source code for these four tests are here on GitHub for you to check out.
Testing With react-testing-library
We’ll write three tests for this:
- That the initial to-do renders
- That we can add a new to-do
- That we can delete a to-do
Let’s start by installing the packages we need:
npm install --save-dev @testing-library/jest-dom @testing-library/react
Next, we can import the packages and files:
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import Todo from "../Todo";
import "@testing-library/jest-dom/extend-expect";
test("Todo", () => {
// Tests go here
}
Test 1: The initial to-do renders
We’ll write our tests in a test
block. The first test will look like this:
it("displays initial to-dos", () => {
const { getByTestId } = render(<Todo />);
const todos = getByTestId("todos");
expect(todos.children.length).toBe(2);
});
What’s happening here? We’re making use of getTestId
to return the node of the element where data-testid
matches the one that was passed to the method. That’s the <ul>
element in this case. Then, we’re checking that it has a total of two children (each child being a <li>
element inside the unordered list). This will pass as the initial to-do is equal to two.
Test 2: We can add a new to-do
We’re also making use of getTestById
here to return the node that matches the argument we’re passing in.
it("adds a new to-do", () => {
const { getByTestId, getByText } = render(<Todo />);
const input = getByTestId("input");
const todos = getByTestId("todos");
input.value = "Fix failing tests";
fireEvent.click(getByText("Add Task"));
expect(todos.children.length).toBe(3);
});
We use getByTestId
to return the input field and the ul
element like we did before. To simulate a click event that adds a new to-do item, we’re using fireEvent.click()
and passing in the getByText()
method, which returns the node whose text matches the argument we passed. From there, we can then check to see the length of the to-dos by checking the length of the children array.
Test 3: We can delete a to-do
This will look a little like what we did a little earlier:
it("deletes a to-do", () => {
const { getAllByTestId, getByTestId } = render(<Todo />);
const todos = getByTestId("todos");
const deleteButton = getAllByTestId("delete-button");
const first = deleteButton[0];
fireEvent.click(first);
expect(todos.children.length).toBe(1);
});
We’re making use of getAllByTestId
to return the nodes of the delete button. Since we only want to delete one item, we fire a click event on the first item in the collection, which should delete the first to-do. This should then make the length of todos
children equal to one.
These tests are also available on GitHub.
Linting
There are two lint rules to abide by when working with hooks:
Rule 1: Call hooks at the top level
…as opposed to inside conditionals, loops or nested functions.
// Don't do this!
if (Math.random() > 0.5) {
const [invalid, updateInvalid] = useState(false);
}
This goes against the first rule. According to the official documentation, React depends on the order in which hooks are called to associate state and the corresponding useState
call. This code breaks the order as the hook will only be called if the conditions are true.
This also applies to useEffect
and other hooks. Check out the documentation for more details.
Rule 2: Call hooks from React functional components
Hooks are meant to be used in React functional components — not in React’s class component or a JavaScript function.
We’ve basically covered what not to do when it comes to linting. We can avoid these missteps with an npm package that specifically enforces these rules.
npm install eslint-plugin-react-hooks --save-dev
Here’s what we add to the package’s configuration file to make it do its thing:
{
"plugins": [
// ...
"react-hooks"
],
"rules": {
// ...
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
If you are making use of Create React App, then you should know that the package supports the lint plugin out of the box as of v3.0.0.
Go forth and write solid React code!
React hooks are equally prone to error as anything else in your application and you’re gonna want to ensure that you use them well. As we just saw, there’s a couple of ways we can go about it. Whether you use Enzyme or You can either make use of enzyme or React Testing Library to write tests is totally up to you. Either way, try making use of linting as you go, and no doubt, you’ll be glad you did.