The Git tag for this section is appointment-first-name.
In this section, we'll discover the TDD cycle for the first time.
We'll start our application by building out an appointment view. We won't get very far; the tests we'll create in this chapter will simply display the customer who made the appointment. As we do so, we'll discuss the TDD process in detail.
We'll build a React functional component called Appointment. It is used for displaying the details of a single appointment in our system. The component will be passed in a data structure that represents Appointment, which we can imagine looks a little something like this:
{
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 that 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.
But before we get on to that, let's explore Jest a little.
What exactly is a test? We'll discover that by writing one. 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:
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 (or description) 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.
All of the Jest functions 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 your describe blocks the same name as the component itself.
You should run this code right now in the Jest test runner. It will give us valuable information about what to do next. You might think that running tests now is pointless, since we haven't even written a test yet, but with TDD, it's normal to run your test runner at every opportunity.
On the command line, run the npm test command:
> [email protected] test /home/daniel/work/react-tdd/ch1
> jest
FAIL test/Appointment.test.js
● Test suite failed to run
Your test suite must contain at least one test.
at node_modules/jest/node_modules/jest-cli/build/TestScheduler.js:225:24
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 0.917s
Ran all test suites.
npm ERR! Test failed. See above for more details.
You can see Jest helpfully tells us Your test suite must contain at least one test. Test-driven developers rely heavily on listening to the test runner and what it tells us. It usually tells them exactly what to do next. In this case, it's telling us to create a test. So, let's do that.
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.
I don't recommend mixing production code with test code. For a start, it isn’t the conventional unit-testing approach, which uses two separate directories for production code and test code. More importantly, however, it’s likely that you won’t have a one-to-one mapping between production and test files.
Change your describe call to this:
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, remember, it should make 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.
You may have used the test function for Jest, which is equivalent to it. Since we’re doing behavior driven development style of TDD, you should stick with it.
Empty tests, such as the one we just wrote, always pass. Let's change that now. Let's add an expectation to our test. Change test to read as follows:
it('renders the customer first name', () => {
expect(document.body.textContent).toMatch('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 toMatch the string Ashley.
Although it might look complicated, it's quite a simple idea: 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.
The toMatch function is called a matcher and there are a whole lot of different matchers that work in different ways. In this case, the expectation passes if document.body.textContent has the word Ashley anywhere within it.
Each individual test can have as many expectations in it as you like, and we'll see examples of multiple expectations in a test later in this chapter.
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?
Let's run test now, with npm test, and find out:
FAIL test/Appointment.test.js
Appointment
✕ renders the customer first name (10ms)
● Appointment › renders the customer first name
expect(received).toMatch(expected)
Expected value to match:
"Ashley"
Received:
""
1 | describe('Appointment', () => {
2 | it('renders the customer first name', () => {
> 3 | expect(document.body.textContent).toMatch('Ashley');
| ^
4 | });
5 | });
6 |
at Object.toMatch (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 where our tests failed: document.body.textContent is empty. This isn't surprising really, since we've not done anything to set the body text.
But, hold on a second. Where did document.body come from? No one defined that yet. Shouldn’t we expect the test to fail with an error saying that the document is undefined?
Jest magically includes a DOM implementation for us, which is why we have access to document and document.body. It uses jsdom, a headless implementation of the DOM. We can do test browser interactions on the command line, which is much simpler than involving a browser in our work.
In Jest lingo, this is called the Jest environment and it defaults to jsdom. If you want to verify that this is happening, add the following config to your package.json file:
"jest": {
"testEnvironment": "node"
}
Re-run tests and observe the different output to convince yourself that JSDOM is no longer present.
Be sure to remove this extra configuration before you continue, as we’ll be relying on the JSDOM environment from now on.
In order to make this test pass, we'll have to write some code above the expectation that will call into our production code.
Since we're testing what happens when a React component is rendered, we'll need to call the ReactDOM.render function. This function takes a component (which in our case will be called Appointment), performs the React render magic, and replaces an existing DOM node with the newly rendered node tree. The DOM node it replaces is known as the React container.
Here's the method signature:
ReactDOM.render(component, container)
In order to call this in our test, we'll need to define both component and container. Let's piece the test together before we write it out in full. It will have this shape:
it('renders the customer first name', () => {
const component = ???
const container = ???
ReactDOM.render(component, container);
expect(document.body.textContent).toMatch('Ashley');
});
Since we're rendering Appointment, we know what we need to put for component. It's a JSX fragment that takes our customer as a prop:
const customer = { firstName: 'Ashley' };
const component = <Appointment customer={customer} />;
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.
What about container? We can use the DOM to create a container element:
const container = document.createElement('div');
document.body.appendChild(container);
Now let's take a look at that test in full. Change your test in test/Appointments.test.js to match the following:
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.render(component, container);
expect(document.body.textContent).toMatch('Ashley');
});
As we're using both ReactDOM and JSX, we'll need to include the two standard React import at the top of our test file for this to work, as follows:
import React from 'react';
import ReactDOM from 'react-dom';
Go ahead and run the test. Within the output, you'll see the following:
ReferenceError: Appointment is not defined
This is subtly different from the test failure we saw previously. This is a run-time exception, not an expectation failure. Thankfully, though, the exception is telling us exactly what we need to do, just as a test expectation would. We need to define Appointment.
We're now ready to make failing test pass:
- Add import to test/Appointment.test.js, below the two React imports:
import { Appointment } from '../src/Appointment';
- Run tests with npm test. You'll get a different error this time:
Cannot find module '../src/Appointment' from 'Appointment.test.js'
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 from of import (import { ... }). I tend to avoid using default exports; doing so keeps the name of my component and its usage in sync: if I change the name of a component, then every place where it's imported will break unless I change those too. This isn't the case with default exports. Once your names are out of sync, it can be hard to track where components are used.
- Let's create that module. Type the following at your command line:
mkdir src
touch src/Appointment.js
- In your editor, add the following content to src/Appointment.js:
export const Appointment = () => {};
Why have I created a shell of an Appointment without actually creating an implementation? This might seem pointless, but another core principle of the test-driven developer 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 I 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:
- Run npm test. You'll get a lot of React output as a large stack trace. If you scroll up to the top, you'll see this:
Error: Uncaught [Invariant Violation: Appointment(...): Nothing was returned from render. This usually means a return statement is missing. Or, to render nothing, return null.]
- To fix that, we need to do what it's telling us: we need to return something "from render". So, let's return something. Change the file to read as follows:
import React from 'react';
export const Appointment = () => <div></div>;
- Now, if you run the test, you should get a test failure:
FAIL test/Appointment.test.js
Appointment
✕ renders the customer first name (23ms)
● Appointment › renders the customer first name
expect(received).toMatch(expected)
Expected value to match:
"Ashley"
Received:
""
- To fix the test, change the Appointment definition to look like this:
export const Appointment = () => (
<div>Ashley</div>
);
But, wait a second. This test isn't using our appointment variable that we defined in our test. We just hard-coded a value of Ashley in there!
Remember our principle: always implement the simplest thing that will possibly work. That includes hard-coding, when it's possible. In order to get to the real implementation, we need to add more tests. This process is called triangulation. The more specific our tests get, the more general our production code needs to get.
This is one reason why pair programming using TDD can be so fun. Pairs can play ping pong. Sometimes, your pair will write a test that you can solve trivially, perhaps by hard-coding, and then you force them to do the hard work of both tests by triangulating. They need to remove the hard-coding and add the generalization.
Let's triangulate:
- 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);
ReactDOM.render(component, container);
expect(document.body.textContent).toMatch('Jordan');
});
- Run tests with npm test. We expect this test to fail, and it does. Take a careful look at this output:
FAIL test/Appointment.test.js
Appointment
✓ renders the customer name (19ms)
✕ renders another customer name (20ms)
● Appointment › renders another customer name
expect(received).toMatch(expected)
Expected value to match:
"Jordan"
Received:
"AshleyAshley"
Yes, it did fail—but with the text AshleyAshley!
This kind of repeated text is an indicator that our tests are not running independently of one another. There is some shared state that isn't being cleared. We need to change course and uncover what's going on.
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.
There's only one piece of shared state that our tests use and that's document. It must not be getting cleared each time the tests are run, and so we see the output of each test inside the document.
Even if we fixed our production code to remove the hard-coding, it still wouldn't pass; instead, we'd see the text AshleyJordan.
One solution is to clear the document DOM tree before each test run. But there's a simpler solution: we can rework our tests to not append our container element to the DOM at all, and instead work directly with the container element. In other words, we can change our expectation to check not document.body.textContent but container.textContent.
There may come a time that we actually need to attach our nodes to the DOM, and at that point, we'll need to fix this problem properly. But for now, you ain't gonna need it. So, let's solve this by avoiding the DOM tree altogether. It's the simplest way forward.
Unfortunately, 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.
What we'll have to do is ignore, or pend, this test we're working on. We do that by changing the word it to it.skip. Do that now for the second test:
it.skip('renders another customer first name', () => {
Run tests. You'll see Jest ignores the second test, and the first one still passes:
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
For this refactor, we need to make two changes:
- Change the expectation to match on container.textContent.
- Remove the line that calls appendChild on the document body.
We can also take this opportunity to inline the component variable. Change the test to read as follows:
it('renders the customer first name', () => {
const customer = { firstName: 'Ashley' };
const container = document.createElement('div');
ReactDOM.render(<Appointment customer={customer} />, container);
expect(container.textContent).toMatch('Ashley');
});
Run your tests: the result should be the same as earlier, with one passing test and one skipped.
It's time to bring that second test back in, by removing the .skip from the function name, and this time, let's update the test code to make the same changes we made in the first, as follows:
it('renders another customer first name', () => {
const customer = { firstName: 'Jordan' };
const container = document.createElement('div');
ReactDOM.render(<Appointment customer={customer} />, container);
expect(container.textContent).toMatch('Jordan');
});
Running tests now should give us the error that we were originally expecting:
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).toMatch(expected)
Expected value to match:
"Jordan"
Received:
"Ashley"
To fix this, we need to introduce the variable and use it within our JSX, which supports embedding JavaScript expressions within elements. We can also use destructuring assignment to avoid creating unnecessary variables.
Change the definition of Appointment to look as follows:
export const Appointment = ({ customer }) => (
<div>{customer.firstName}</div>
);
Note that I haven't fully destructured this. I could have written this function like this:
export const Appointment = ({ customer: { firstName } }) => (
<div>{firstName}</div>
);
The first version is no longer than the second; however, if you're counting tokens, it has one less set of curly braces. The most concise solution always wins!
Run tests; we expect this test to now pass, as follows:
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.