Book Image

Mastering React Test-Driven Development - Second Edition

By : Daniel Irvine
Book Image

Mastering React Test-Driven Development - Second Edition

By: Daniel Irvine

Overview of this book

Test-driven development (TDD) is a programming workflow that helps you build your apps by specifying behavior as automated tests. The TDD workflow future-proofs apps so that they can be modified without fear of breaking existing functionality. Another benefit of TDD is that it helps software development teams communicate their intentions more clearly, by way of test specifications. This book teaches you how to apply TDD when building React apps. You’ll create a sample app using the same React libraries and tools that professional React developers use, such as Jest, React Router, Redux, Relay (GraphQL), Cucumber, and Puppeteer. The TDD workflow is supported by various testing techniques and patterns, which are useful even if you’re not following the TDD process. This book covers these techniques by walking you through the creation of a component test framework. You’ll learn automated testing theory which will help you work with any of the test libraries that are in standard usage today, such as React Testing Library. This second edition has been revised with a stronger focus on concise code examples and has been fully updated for React 18. By the end of this TDD book, you’ll be able to use React, Redux, and GraphQL to develop robust web apps.
Table of Contents (26 chapters)
1
Part 1 – Exploring the TDD Workflow
10
Part 2 – Building Application Features
16
Part 3 – Interactivity
20
Part 4 – Behavior-Driven Development with Cucumber

Displaying data with your first test

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:

  1. In your project directory, type the following commands:
    mkdir test
    touch test/Appointment.test.js
  2. 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:

  1. 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"
      );
    });
  2. 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";
  3. 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:

  1. Add a new import statement to test/Appointment.test.js, below the two React imports, as follows:
    import { Appointment } from "../src/Appointment";
  2. 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.

  1. Let’s create that module. Type the following code in your command prompt:
    mkdir src
    touch src/Appointment.js
  2. 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:

  1. Go to the top of test/Appointment.test.js and add the following line of code:
    import { act } from "react-dom/test-utils";
  2. Then, change the line with the render call to read as follows:
    act(() => 
      ReactDOM.createRoot(container).render(component)
    );
  3. 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.

  1. 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
        }
      }
    }
  2. 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:

  1. 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"
      );
    });
  2. 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:

  1. 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", () => {
      ...
    });
  2. 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
  3. 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"
      );
    });
  4. 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.

  1. 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"
      );
    });
  2. 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"
  3. 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>
    );
  4. 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.