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.
Writing a failing test
What exactly is a test? To answer that, let’s write one. Perform the following steps:
- In your project directory, type the following commands:
mkdir test
touch test/Appointment.test.js
- Open the
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.
Writing your first expectation
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:
- The name of the failing test
- The expected answer
- The actual answer
- The location in the source where the error occurred
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.
Rendering React components from within a test
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:
- Change your test in
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"
);
});
- As we’re using both the
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";
- Go ahead and run the test; it’ll fail. Within the output, you’ll see the following code:
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
.
Make it pass
We’re now ready to make the failing test pass. Perform the following steps:
- Add a new
import
statement to test/Appointment.test.js
, below the two React imports, as follows:import { Appointment } from "../src/Appointment";
- Run tests with
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.
- Let’s create that module. Type the following code in your command prompt:
mkdir src
touch src/Appointment.js
- In your editor, add the following content to
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?
Making use of the act test helper
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:
- Go to the top of
test/Appointment.test.js
and add the following line of code:import { act } from "react-dom/test-utils";
- Then, change the line with the
render
call to read as follows:act(() =>
ReactDOM.createRoot(container).render(component)
);
- Now rerun your test and you should see a passing test, but with an odd warning printed above it, like this:
> 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.
- Let’s go ahead and enable the
act
function. Open package.json
and modify your jest
property to read as follows:{
...,
"jest": {
"testEnvironment": "jsdom",
"globals": {
"IS_REACT_ACT_ENVIRONMENT": true
}
}
}
- Now run your test again with
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.
Triangulating to remove hardcoding
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:
- Make a copy of your first test, pasting it just under the first test, and change the test description and the name of
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"
);
});
- Run tests with
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.
Backtracking on ourselves
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:
- In the first test you’ve just written, change
it
to it.skip
. Do that now for the second test as follows:it.skip("renders another customer first name", () => {
...
});
- Run tests. You’ll see that Jest ignores the second test and the first one still passes, as follows:
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
- In the first test, change
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"
);
});
- Rerun the tests with
npm test
. It should still be passing.
It’s time to bring the skipped test back in by removing .skip
from the function name.
- Perform the same update in this test as in the first: change
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"
);
});
- Running tests now should give us the error that we were originally expecting. No more repeated text content, as you can see:
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"
- To make the test pass, we need to introduce the prop and use it within our component. Change the definition of
Appointment
to look as follows, destructuring the function arguments to pull out the customer prop:export const Appointment = ({ customer }) => (
<div>{customer.firstName}</div>
);
- Run tests. We expect this test to now pass:
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.