-
Book Overview & Buying
-
Table Of Contents
-
Feedback & Rating

Mastering React Test-Driven Development
By :

Now we’ll use the TDD cycle for the first time, which you’ll learn about as we go through each step of the cycle.
We’ll start our application by building out an appointment view, which shows the details of an appointment. It’s a React component called Appointment
that will be passed in a data structure that represents an appointment at the hair salon. We can imagine it looks a little something like the following example:
{ customer: { firstName: "Ashley", lastName: "Jones", phoneNumber: "(123) 555-0123" }, stylist: "Jay Speares", startsAt: "2019-02-02 09:30", service: "Cut", notes: "" }
We won’t manage to get all of this information displayed by the time we complete the chapter; in fact, we’ll only display the customer’s firstName
, and we’ll make use of the startsAt
timestamp to order a list of today’s appointments.
In the following few subsections, you’ll write your first Jest test and go through all of the necessary steps to make it pass.
What exactly is a test? To answer that, let’s write one. Perform the following steps:
mkdir test touch test/Appointment.test.js
test/Appointment.test.js
file in your favorite editor or IDE and enter the following code:describe("Appointment", () => { });
The describe
function defines a test suite, which is simply a set of tests with a given name. The first argument is the name of the unit you are testing. It could be a React component, a function, or a module. The second argument is a function inside of which you define your tests. The purpose of the describe
function is to describe how this named “thing” works—whatever the thing is.
Global Jest functions
All of the Jest functions (such as describe
) are already required and available in the global namespace when you run the npm test
command. You don’t need to import anything.
For React components, it’s good practice to give describe
blocks the same name as the component itself.
Where should you place your tests?
If you do try out the create-react-app
template, you’ll notice that it contains a single unit test file, App.test.js
, which exists in the same directory as the source file, App.js
.
We prefer to keep our test files separate from our application source files. Test files go in a directory named test
and source files go in a directory named src
. There is no real objective advantage to either approach. However, do note that it’s likely that you won’t have a one-to-one mapping between production and test files. You may choose to organize your test files differently from the way you organize your source files.
Let’s go ahead and run this with Jest. You might think that running tests now is pointless, since we haven’t even written a test yet, but doing so gives us valuable information about what to do next. With TDD, it’s normal to run your test runner at every opportunity.
On the command line, run the npm test command again. You will see this output:
No tests found, exiting with code 1 Run with `--passWithNoTests` to exit with code 0
That makes sense—we haven’t written any tests yet, just a describe
block to hold them. At least we don’t have any syntax errors!
Tip
If you instead saw the following:
> echo "Error: no test specified" && exit 1
You need to set Jest as the value for the test command in your package.json
file. See Step 3 in Creating a new Jest project above.
Change your describe
call as follows:
describe("Appointment", () => { it("renders the customer first name", () => { }); });
The it
function defines a single test. The first argument is the description of the test and always starts with a present-tense verb so that it reads in plain English. The it
in the function name refers to the noun you used to name your test suite (in this case, Appointment
). In fact, if you run tests now, with npm test
, the ouput (as shown below) will make good sense:
PASS test/Appointment.test.js Appointment ✓ renders the customer first name (1ms)
You can read the describe
and it
descriptions together as one sentence: Appointment renders the customer first name. You should aim for all of your tests to be readable in this way.
As we add more tests, Jest will show us a little checklist of passing tests.
Jest’s test function
You may have used the test
function for Jest, which is equivalent to it
. We prefer it
because it reads better and serves as a helpful guide for how to succinctly describe our test.
You may have also seen people start their test descriptions with “should…”. I don’t really see the point in this, it’s just an additional word we have to type. Better to just use a well-chosen verb to follow the “it.”
Empty tests, such as the one we just wrote, always pass. Let’s change that now. Add an expectation to our test as follows:
it("renders the customer first name", () => { expect(document.body.textContent).toContain("Ashley"); });
This expect
call is an example of a fluent API. Like the test description, it reads like plain English. You can read it like this:
I expect document.body.textContent
toContain
the string Ashley
.
Each expectation has an expected value that is compared against a received value. In this example, the expected value is Ashley
and the received value is whatever is stored in document.body.textContent
. In other words, the expectation passes if document.body.textContent
has the word Ashley
anywhere within it.
The toContain
function is called a matcher
and there are a whole lot of different matchers that work in different ways. You can (and should) write your own matchers. You’ll discover how to do that in Chapter 3, Refactoring the Test Suite. Building matchers that are specific to your own project is an essential part of writing clear, concise tests.
Before we run this test, spend a minute thinking about the code. You might have guessed that the test will fail. The question is, how will it fail?
Run the npm test
command and find out:
FAIL test/Appointment.test.js Appointment ✕ renders the customer first name (1 ms) ● Appointment › renders the customer first name The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string. Consider using the "jsdom" test environment. ReferenceError: document is not defined 1 | describe("Appointment", () => { 2 | it("renders the customer first name", () => { > 3 | expect(document.body.textContent).toContain("Ashley"); | ^ 4 | }); 5 | }) 6 | at Object.<anonymous> (test/Appointment.test.js:3:12)
We have our first failure!
It’s probably not the failure you were expecting. Turns out, we still have some setup to take care of. Jest helpfully tells us what it thinks we need, and it’s correct; we need to specify a test environment of jsdom
.
A test environment is a piece of code that runs before and after your test suite to perform setup and teardown. For the jsdom
test environment, it instantiates a new JSDOM
object and sets global and document objects, turning Node.js into a browser-like environment.
jsdom is a package that contains a headless implementation of the Document Object Model (DOM) that runs on Node.js. In effect, it turns Node.js into a browser-like environment that responds to the usual DOM APIs, such as the document API we’re trying to access in this test.
Jest provides a pre-packaged jsdom
test environment that will ensure our tests run with these DOM APIs ready to go. We just need to install it and instruct Jest to use it.
Run the following command at your command prompt:
npm install --save-dev jest-environment-jsdom
Now we need to open package.json
and add the following section at the bottom:
{ ..., "jest": { "testEnvironment": "jsdom" } }
Then we run npm test
again, giving the following output:
FAIL test/Appointment.test.js Appointment ✕ renders the customer first name (10ms) ● Appointment › renders the customer first name expect(received).toContain(expected) Expected substring: "Ashley" Received string: "" 1 | describe("Appointment", () => { 2 | it("renders the customer first name", () => { > 3 | expect(document.body.textContent).toContain("Ashley"); | ^ 4 | }); 5 | }); 6 | at Object.toContain (test/Appointment.test.js:3:39)
There are four parts to the test output that are relevant to us:
All of these help us to pinpoint why our tests failed: document.body.textContent
is empty. That’s not surprising given we haven’t written any React code yet.
In order to make this test pass, we’ll have to write some code above the expectation that will call into our production code.
Let’s work backward from that expectation. We know we want to build a React component to render this text (that’s the Appointment
component we specified earlier). If we imagine we already have that component defined, how would we get React to render it from within our test?
We simply do the same thing we’d do at the entry point of our own app. We render our root component like this:
ReactDOM.createRoot(container).render(component);
The preceding function replaces the DOM container
element with a new element that is constructed by React by rendering our React component
, which in our case will be called Appointment
.
The createRoot function
The createRoot
function is new in React 18. Chaining it with the call to render
will suffice for most of our tests, but in Chapter 7, Testing useEffect and Mocking Components, you’ll adjust this a little to support re-rendering in a single test.
In order to call this in our test, we’ll need to define both component
and container
. The test will then have the following shape:
it("renders the customer first name", () => { const component = ??? const container = ??? ReactDOM.createRoot(container).render(component); expect(document.body.textContent).toContain("Ashley"); });
The value of component
is easy; it will be an instance of Appointment
, the component under test. We specified that as taking a customer as a prop, so let’s write out what that might look like now. Here’s a JSX fragment that takes customer
as a prop:
const customer = { firstName: "Ashley" }; const component = <Appointment customer={customer} />;
If you’ve never done any TDD before, this might seem a little strange. Why are we writing test code for a component we haven’t yet built? Well, that’s partly the point of TDD – we let the test drive our design. At the beginning of this section, we formulated a verbal specification of what our Appointment
component was going to do. Now, we have a concrete, written specification that can be automatically verified by running the test.
Simplifying test data
Back when we were considering our design, we came up with a whole object format for our appointments. You might think the definition of a customer here is very sparse, as it only contains a first name, but we don’t need anything else for a test about customer names.
We’ve figured out component
. Now, what about container
? We can use the DOM to create a container
element, like this:
const container = document.createElement("div");
The call to document.createElement
gives us a new HTML element that we’ll use as our rendering root. However, we also need to attach it to the current document body. That’s because certain DOM events will only register if our elements are part of the document tree. So, we also need to use the following line of code:
document.body.appendChild(container);
Now our expectation should pick up whatever we render because it’s rendered as part of document.body
.
Warning
We won’t be using appendChild
for long; later in the chapter, we’ll be switching it out for something more appropriate. We would not recommend using appendChild
in your own test suites for reasons that will become clear!
Let’s put it all together:
test/Appointments.test.js
as follows:it("renders the customer first name", () => { const customer = { firstName: "Ashley" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.appendChild(container); ReactDOM.createRoot(container).render(component); expect(document.body.textContent).toContain( "Ashley" ); });
ReactDOM
namespace and JSX, we’ll need to include the two standard React imports at the top of our test file for this to work, as shown below:import React from "react"; import ReactDOM from "react-dom/client";
ReferenceError: Appointment is not defined 5 | it("renders the customer first name", () => { 6 | const customer = { firstName: "Ashley" }; > 7 | const component = ( 8 | <Appointment customer={customer} /> | ^ 9 | );
This is subtly different from the test failure we saw earlier. This is a runtime exception, not an expectation failure. Thankfully, though, the exception is telling us exactly what we need to do, just as a test expectation would. It’s finally time to build Appointment
.
We’re now ready to make the failing test pass. Perform the following steps:
import
statement to test/Appointment.test.js
, below the two React imports, as follows:import { Appointment } from "../src/Appointment";
npm test
. You’ll get a different error this time, with the key message being this:Cannot find module '../src/Appointment' from 'Appointment.test.js'
Default exports
Although Appointment
was defined as an export, it wasn’t defined as a default export. That means we have to import it using the curly brace form of import (import { ... }
). We tend to avoid using default exports as doing so keeps the name of our component and its usage in sync. If we change the name of a component, then every place where it’s imported will break until we change those, too. This isn’t the case with default exports. Once your names are out of sync, it’s harder to track where components are used—you can’t simply use text search to find them.
mkdir src touch src/Appointment.js
src/Appointment.js
:export const Appointment = () => {};
Why have we created a shell of Appointment
without actually creating an implementation? This might seem pointless, but another core principle of TDD is always do the simplest thing to pass the test. We could rephrase this as always do the simplest thing to fix the error you’re working on.
Remember when we mentioned that we listen carefully to what the test runner tells us? In this case, the test runner said Cannot
find module Appointment
, so what was needed was to create that module, which we’ve done, and then immediately stopped. Before we do anything else, we need to run our tests to learn what’s the next thing to do.
Running npm test
again, you should get this test failure:
● Appointment › renders the customer first name expect(received).toContain(expected) Expected substring: "Ashley" Received string: "" 12 | ReactDOM.createRoot(...).render(component); 13 | > 14 | expect(document.body.textContent).toContain( | ^ 15 | "Ashley" 16 | ); 17 | }); at Object.<anonymous> (test/Appointment.test.js:14:39)
To fix the test, let’s change the Appointment
definition as follows:
export const Appointment = () => "Ashley";
You might be thinking, “That’s not a component! There’s no JSX.” Correct. “And it doesn’t even use the customer prop!” Also correct. But React will render it anyway, and theoretically, it should make the test pass; so, in practice, it’s a good enough implementation, at least for now.
We always write the minimum amount of code that makes a test pass.
But does it pass? Run npm test
again and take a look at the output:
● Appointment › renders the customer first name expect(received).toContain(expected) Expected substring: "Ashley" Received string: "" 12 | ReactDOM.createRoot(...).render(component); 13 | > 14 | expect(document.body.textContent).toContain( 15 | ^ 16 | "Ashley" 17 | ); | });
No, it does not pass. This is a bit of a headscratcher. We did define a valid React component. And we did tell React to render it in our container. What’s going on?
In a React testing situation like this, often the answer has something to do with the async nature of the runtime environment. Starting in React 18, the render function is asynchronous: the function call will return before React has modified the DOM. Therefore, the expectation will run before the DOM is modified.
React provides a helper function for our tests that pauses until asynchronous rendering has completed. It’s called act
and you simply need to wrap it around any React API calls. To use act
, perform the following steps:
test/Appointment.test.js
and add the following line of code:import { act } from "react-dom/test-utils";
render
call to read as follows:act(() => ReactDOM.createRoot(container).render(component) );
> jest console.error Warning: The current testing environment is not configured to support act(...) at printWarning (node_modules/react-dom/cjs/react-dom.development.js:86:30)
React would like us to be explicit in our use of act
. That’s because there are use cases where act
does not make sense—but for unit testing, we almost certainly want to use it.
Understanding the act function
Although we’re using it here, the act
function is not required for testing React. For a detailed discussion on this function and how it can be used, head to https://reacttdd.com/understanding-act.
act
function. Open package.json
and modify your jest
property to read as follows:{ ..., "jest": { "testEnvironment": "jsdom", "globals": { "IS_REACT_ACT_ENVIRONMENT": true } } }
npm test
, giving the output shown:> jest PASS test/Appointment.test.js Appointment ✓ renders the customer first name (13 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.355 s Ran all test suites.
Finally, you have a passing test, with no warnings!
In the following section, you will discover how to remove the hardcoded string value that you’ve introduced by adding a second test.
Now that we’ve got past that little hurdle, let’s think again about the problems with our test. We did a bunch of strange acrobatics just to get this test passing. One odd thing was the use of a hardcoded value of Ashley
in the React component, even though we’d gone to the trouble of defining a customer prop in our test and passing it in.
We did that because we want to stick to our rule of only doing the simplest thing that will make a test pass. In order to get to the real implementation, we need to add more tests.
This process is called triangulation. We add more tests to build more of a real implementation. The more specific our tests get, the more general our production code needs to get.
Ping pong programming
This is one reason why pair programming using TDD can be so enjoyable. Pairs can play ping pong. Sometimes, your pair will write a test that you can solve trivially, perhaps by hardcoding, and then you force them to do the hard work of both tests by triangulating. They need to remove the hardcoding and add the generalization.
Let’s triangulate by performing the following steps:
Ashley
to Jordan
, as follows:it("renders another customer first name", () => { const customer = { firstName: "Jordan" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.appendChild(container); act(() => ReactDOM.createRoot(container).render(component) ); expect(document.body.textContent).toContain( "Jordan" ); });
npm test
. We expect this test to fail, and it does. But examine the code carefully. Is this what you expected to see? Take a look at the value of Received string
in the following code:FAIL test/Appointment.test.js Appointment ✓ renders the customer first name (18ms) ✕ renders another customer first name (8ms) ● Appointment › renders another customer first name expect(received).toContain(expected) Expected substring: "Jordan" Received string: "AshleyAshley"
The document body has the text AshleyAshley
. This kind of repeated text is an indicator that our tests are not independent of one another. The component has been rendered twice, once for each test. That’s correct, but the document isn’t being cleared between each test run.
This is a problem. When it comes to unit testing, we want all tests to be independent of one other. If they aren’t, the output of one test could affect the functionality of a subsequent test. A test might pass because of the actions of a previous rest, resulting in a false positive. And even if the test did fail, having an unknown initial state means you’ll spend time figuring out if it was the initial state of the test that caused the issue, rather than the test scenario itself.
We need to change course and fix this before we get ourselves into trouble.
Test independence
Unit tests should be independent of one another. The simplest way to achieve this is to not have any shared state between tests. Each test should only use variables that it has created itself.
We know that the shared state is the problem. Shared state is a fancy way of saying “shared variables.” In this case, it’s document
. This is the single global document
object that is given to us by the jsdom
environment, which is consistent with how a normal web browser operates: there’s a single document
object. But unfortunately, our two tests use appendChild
to add into that single document that’s shared between them. They don’t each get their own separate instance.
A simple solution is to replace appendChild
with replaceChildren
, like this:
document.body.replaceChildren(container);
This will clear out everything from document.body
before doing the append.
But there’s a problem. We’re in the middle of a red test. We should never refactor, rework, or otherwise change course while we’re red.
Admittedly, this is all highly contrived—we could have used replaceChildren
right from the start. But not only are we proving the need for replaceChildren
, we are also about to discover an important technique for dealing with just this kind of scenario.
What we’ll have to do is skip this test we’re working on, fix the previous test, then re-enable the skipped test. Let’s do that now by performing the following steps:
it
to it.skip
. Do that now for the second test as follows:it.skip("renders another customer first name", () => { ... });
PASS test/Appointment.test.js Appointment ✓ renders the customer first name (19ms) ○ skipped 1 test Test Suites: 1 passed, 1 total Tests: 1 skipped, 1 passed, 2 total
appendChild
to replaceChildren
as follows:it("renders the customer first name", () => { const customer = { firstName: "Ashley" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.replaceChildren(container); ReactDOM.createRoot(container).render(component); expect(document.body.textContent).toContain( "Ashley" ); });
npm test
. It should still be passing.It’s time to bring the skipped test back in by removing .skip
from the function name.
appendChild
to replaceChildren
, like this:it("renders another customer first name", () => { const customer = { firstName: "Jordan" }; const component = ( <Appointment customer={customer} /> ); const container = document.createElement("div"); document.body.replaceChildren(container); act(() => ReactDOM.createRoot(container).render(component) ); expect(document.body.textContent).toContain( "Jordan" ); });
FAIL test/Appointment.test.js Appointment ✓ renders the customer first name (18ms) ✕ renders another customer first name (8ms) ● Appointment › renders another customer first name expect(received).toContain(expected) Expected substring: "Jordan" Received string: "Ashley"
Appointment
to look as follows, destructuring the function arguments to pull out the customer prop:export const Appointment = ({ customer }) => ( <div>{customer.firstName}</div> );
PASS test/Appointment.test.js Appointment ✓ renders the customer first name (21ms) ✓ renders another customer first name (2ms)
Great work! We’re done with our passing test, and we’ve successfully triangulated to remove hardcoding.
In this section, you’ve written two tests and, in the process of doing so, you’ve discovered and overcome some of the challenges we face when writing automated tests for React components.
Now that we’ve got our tests working, we can take a closer look at the code we’ve written.
Change the font size
Change margin width
Change background colour