Imagine you've spent weeks building an application, and it's now live and working well. Users are happy with it. Then, a few months later, your boss asks for some new features. You dive into the project and start writing code, but suddenly, you begin to notice a lot of errors popping up. Fixing them takes a considerable amount of time because you have to remember what each piece of code does and where it fits. This is where tests in your code can make a big difference. Writing tests gives you the confidence to make changes to your code without worrying.
In this comprehensive guide, we'll explore Test-Driven Development (TDD) in the context of building a React.js application. We'll focus on the fundamentals of testing a React application. We'll create our own React application using the basic building blocks and even develop our own testing helper functions.
Building a React Application from Scratch
While create-react-app
is great for quickly setting up a React project, in this case, we won't need all the boilerplate that comes with it. We want to stick to the core React functionalities. This follows the first TDD principle, YAGNI (You Ain’t Gonna Need It), which simply implies that you should only add libraries and code that you need to prevent technical debt in the future.
Installing Dependencies
Before we start, ensure that you have npm
installed. If not, head to https://nodejs.org
for installation instructions. Once that's done, create a folder for our application and follow these steps:
- Initialize the project with
npm init -y
. - Open the
package.json
file and set thetest
property to usejest
. - Install Jest by running
npm install --save-dev jest
. - Install React with
npm install --save react react-dom
. - Install Babel for transpiling your code with the following commands:
npm install --save-dev @babel/preset-env @babel/preset-react
npm install --save-dev @babel/plugin-transform-runtime
-
npm install --save @babel/runtime
.
- Configure Babel to use the plugins you just installed by creating a new file,
.babelrc
, with the following content:
{
"presets": ["@babel/env", "@babel/react"],
"plugins": ["@babel/transform-runtime"]
}
Creating Your First Test
Let's build an online clothing store that displays a list of products on its page. We'll start by creating the first page, the product page. It shows details about a product and will be a React component named Product
. The first step in the TDD cycle is to write a failing test.
We create a test file in test/Product.test.js
with the following content:
describe("Product", () => {
it("renders the title", () => {
});
})
In the above code, the describe
keyword defines a test suite, which is simply a set of tests with a given name. The name could be a React component, a function, or a module. The it
function defines a single test. The first argument is the description of the test and should start with a present-tense verb to make it read in plain English. For example, the test above reads as "Product renders the title."
Note: All Jest functions, such as describe
and it
, are required and available in the global namespace when you run npm run test
, so there is no need to explicitly import them.
When we run npm run test
, the test will pass because empty tests always pass. Let's add an expectation to our test:
describe("Product", () => {
it("renders the title", () => {
expect(document.body.textContent).toContain("iPhone 14 Pro")
});
})
The expect
function compares an expected value against a received value. In our case, the expected value is "iPhone 14 Pro," and the actual value is whatever is inside document.body.textContent
. The expectation will pass only if document.body.textContent
contains the text "iPhone 14 Pro."
When we run the test, we get an error message indicating that Jest is not able to access document
and suggests installing jsdom for our test environment.
Note: A test environment is a piece of code that runs before and after your test suite. In this instance, the jsdom environment sets globals and document objects and turns Node.js into a browser-like environment.
Let's go ahead and install jsdom:
npm install --save-dev jest-environment-jsdom
One more thing, let's update package.json
and tell it to use jsdom as our test environment:
{
...,
"jest": {
"testEnvironment": "jsdom"
}
}
Now, run the test again, and you will see a different error, which is typical of a failing test. This error message can be divided into four parts:
- The name of the failing test.
- The expected answer.
- The actual answer.
- The location in the source where the error occurred.
This is expected since we haven't written any production code yet. To make this test pass, we need to render our Product
component into a React container, just as React works in production.
To render a component in React, follow these steps:
- Create a container.
- Make the container the root.
- Attach our component to the root.
This can be done in one line as follows:
...
ReactDOM.createRoot(container).render(component)
...
To achieve the same in our test, do the following:
- Create the container:
const container = document.createElement('div');
document.body.appendChild(container);
We create the container element and append it to the document. This is necessary because some of the events are only accessible if the element is part of the document tree.
- Create the component:
...
const product = {
title: "iPhone 14 Pro",
}
const component = <Product product={product} />
...
When you put it all together, we have:
describe("Product", () => {
it("renders the title", () => {
const container = document.createElement('div');
document.body.appendChild(container);
const product = {
title: "iPhone 14 Pro",
}
const component = <Product product={product} />
ReactDOM.createRoot(container).render(component)
expect(document.body.textContent).toContain("iPhone 14 Pro")
});
})
We need to include the two standard React imports at the top since we are using ReactDOM and JSX:
import React from "react";
import ReactDOM from "react-dom/client";
When we run the test as it is now, we get a ReferenceError
because Product
is not defined. To make this test pass, we need to create a Product
component and import it in our test.
Create a Product.jsx
file and inside it, enter the following:
export const Product = () => {};
Run the test, and we get a new error
This new error suggests that the string we expected is different from what we received. Let's update our component to fix the test error:
export const Product = () => "iPhone 14 Pro";
When we run the test, we get the same error, which is due to the asynchronous nature of React 18's render
function. This causes the expectation to run before the DOM is modified.
We can fix this by using the helper function act
provided by ReactDOM, which ensures the DOM is modified before other code is executed, making our expectation execute only after the DOM is modified.
We import act
as follows:
import { act } from "react-dom/test-utils";
We also have to update the jest
property in the package.json
:
{
...,
"jest": {
"testEnvironment": "jsdom",
"globals": {
"IS_REACT_ACT_ENVIRONMENT": true
}
}
}
Now, update the line that renders the component to be:
...
act(() =>
ReactDOM.createRoot(container).render(component)
)
...
Run the test again, and you will finally see our test passing with no errors.
Expanding the Test
The above test will pass as long as we expect the content of the body to have "iPhone 14 Pro". However, this is not our desired outcome. To make the test pass for other title values, we introduce a prop to our component. Now, our component should look like this:
export const Product = ({ product }) => <p>{product.title}</p>;
Let's add another test to the suite:
it("renders another the title", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const product = {
title: "Samsung",
};
const component = <Product product={product} />;
act(() => ReactDOM.createRoot(container).render(component));
console.log(document.body.textContent);
expect(document.body.textContent).toContain("Samsung");
});
When we run our test, it should pass. However, there is a small issue. The test is passing because of the way the toContain
works, but when we inspect the textContent
in the second test, we see something strange.
When we run the code above, we get:
This indicates that the components are not independent. The document is not being cleared between renders, and we don't want that to happen. We want each unit of the test to be separate from the other. To fix that, we replace appendChild
with replaceChildren
.
Run the test again, and we should be good to go.
Conclusion:
In this article, we've explored the fundamental principles of Test-Driven Development (TDD) in the context of building a React.js application. We've gone through the process of setting up a basic React application, writing our first failing test, and making it pass by creating the necessary React component and utilizing the act
function to ensure asynchronous code is handled correctly.
By following these TDD practices, you can build more reliable and maintainable React applications that are easier to modify and extend in the future. In the next post in this series, we will refactor the test by extracting the repetitive code.
Top comments (0)