Build simple and maintainable web apps with React, Redux, and GraphQL
Daniel Irvine

BIRMINGHAM—MUMBAI
Copyright © 2022 Packt Publishing
All rights reserved. No part of this book may be reproduced, stored in a retrieval system, or transmitted in any form or by any means, without the prior written permission of the publisher, except in the case of brief quotations embedded in critical articles or reviews.
Every effort has been made in the preparation of this book to ensure the accuracy of the information presented. However, the information contained in this book is sold without warranty, either express or implied. Neither the author, nor Packt Publishing or its dealers and distributors, will be held liable for any damages caused or alleged to have been caused directly or indirectly by this book.
Packt Publishing has endeavored to provide trademark information about all of the companies and products mentioned in this book by the appropriate use of capitals. However, Packt Publishing cannot guarantee the accuracy of this information.
Group Product Manager: Pavan Ramchandani
Publishing Product Manager: Bhavya Rao
Senior Editor: Aamir Ahmed
Senior Content Development Editor: Feza Shaikh
Technical Editor: Saurabh Kadave
Copy Editor: Safis Editing
Project Coordinator: Manthan Patel
Proofreader: Safis Editing
Indexer: Manju Arasan
Production Designer: Aparna Bhagat
Marketing Coordinator: Anamika Singh and Marylou De Mello
First published: May 2019
Second edition: September 2022
Production reference: 2130922
Published by Packt Publishing Ltd.
Livery Place
35 Livery Street
Birmingham
B3 2PB, UK.
ISBN 978-1-80324-712-0
For as long as test-driven development (TDD) has existed, there has been debate over whether TDD is feasible for user interface work—even among its staunchest proponents. When people built tools to prove that it’s possible, the debate shifted to whether TDD is as valuable for UIs, considering that programmers can get rapid feedback by seeing and interacting with an interface. Even when TDD’s value is evidenced by smaller, more consistent, single-responsibility units of UI code, some would question whether the same could have been accomplished with less effort by simply following established design patterns.
Is TDD worth your time? Will it result in fewer bugs in your code? Will it improve the design of your system? Will it make future maintenance easier? Maybe.
In the fifteen-or-so years that I’ve been learning, practicing, and teaching test-driven development I have oscillated back-and-forth. Sometimes I relentlessly pursue 100% code coverage, and other times I’ll build worrisomely large applications with no automated tests at all. What I’ve found—and what might surprise some readers—is that my code basically turns out the same whether I practice TDD or not: a similar frequency of bugs, the same idiosyncratic design, and no more or less a burden to maintain.
(At this point, you’d be right to start wondering what this ambivalent foreword is doing in a book designed to teach you TDD.)
The reason that TDD itself doesn’t impact my code very much is because my years of practice have utterly and irrevocably changed me.
I write small single-purpose units, because I’ve felt the sheer exhaustion of writing tests of sprawling unfocused objects.
I avoid mixing levels of abstraction, because I’ve been hopelessly lost in mazes of mock objects that combine testing logic with specifying interactions.
I segregate code coupled to frameworks from feature logic, because I’ve contorted too many tests to fit dependencies that weren’t meant to be tested.
Rigorously practicing TDD transformed my career. Not because it’s the One True Way to program, but because it forces you to ceaselessly ask “how would we test that?” TDD is incredibly challenging at first, but patterns gradually emerge that result in easy-to-test code. And code that’s easy to test, is easy to write. And use. And maintain.
Wherever you are in your journey, I hope this book brings you closer to a similar destination.
Justin Searls VP of Engineering at Test Double
Daniel Irvine is a software consultant based in London. He works with a variety of languages including C#, Clojure, JavaScript, and Ruby. He’s a mentor and coach for junior developers and runs TDD and XP workshops and courses. When he’s not working, he spends time cooking and practicing yoga. He co-founded the Queer Code London meetup and is an active member of the European software craft community.
Emmanuel Demey works with the JavaScript ecosystem every day, and he spends his time sharing his knowledge with anybody. His first goal at work is to help the people that he works with. He speaks at French conferences (Devfest Nantes, Devfest Toulouse, Sunny Tech, Devoxx France, and others) about anything related to the web platform: JavaScript frameworks (Angular, React.js, Vue.js), accessibility, Nest.js, and so on. He has been a trainer for 10 years at Worldline and Zenika (two French consulting companies). He is also a co-leader of the Google Developer of Lille group and a co-organizer of the Devfest Lille conference.
This is a book about dogma. My dogma. It is a set of principles, practices, and rituals that I have found to be extremely beneficial when building React applications. I try to apply these ideas in my daily work, and I believe in them so much that I take every opportunity to teach others about them. That’s why I’ve written this book: to show you the ideas that have helped me be successful in my own career.
As with any dogma, you are free to make your own mind up about it. There are people who will dislike everything about this book. There are those who will love everything about this book. Yet more people will absorb some things and forget others. All of these are fine. The only thing I ask is that you maintain an open mind while you follow along and prepare to have your own dogmas challenged.
Test-driven development (TDD) did not originate in the JavaScript community. However, it is perfectly possible to test-drive JavaScript code. And although TDD is not common in the React community, there’s no reason why it shouldn’t be. In fact, React as a user interface platform is a good fit for TDD because of its elegant model of functional components and state.
So, what is TDD, and why should you use it? TDD is a process for writing software that involves writing tests, or specifications, before writing any code. Its practitioners follow it because they believe that it helps them build and design higher-quality software with longer lifespans, at a lower cost. They believe it offers a mechanism for communicating about design and specification that also doubles up as a rock-solid regression suite. There isn’t much empirical data available that proves any of that to be true, so the best you can do is try it out yourself and make your own mind up.
Perhaps most importantly for me, I find that TDD removes the fear of making changes to my software and makes my working days much less stressful than they used to be. I don’t worry about introducing bugs or regressions into my work because the tests protect me from that.
TDD is often taught with toy examples: to-do lists, temperature converters, tic-tac-toe, and so on. This book teaches two real-world applications. Often, the tests get hairy. We will hit many challenging scenarios and come up with solutions for all of them. There are over 500 tests contained in this book, and each one will teach you something.
Before we begin, a few words of advice.
This is a book about first principles. I believe that learning TDD is about understanding the process in exceptional detail. For that reason, we will not use React Testing Library. Instead, we will build our own test helpers. I am not suggesting that you should avoid these tools in your daily work – I use them myself – but I am suggesting that going without them while you learn is a worthwhile adventure. The benefit of doing so is a deeper understanding and awareness of what those testing libraries are doing for you.
The JavaScript and React landscape changes at such a pace that I can’t claim that this book will remain current for very long. That is another reason why I use a first-principles approach. My hope is that when things do change, you’ll still be able to use this book and apply what you’ve learned to those new scenarios.
Another theme in this book is systematic refactoring, which can come across as rather laborious but is a cornerstone of TDD and other good design practices. I have provided many examples of that within these pages, but for brevity, I sometimes jump straight to a final, refactored solution. For example, I sometimes choose to extract methods before they are written, whereas, in the real world, I would usually write methods inline and only extract when the containing method (or test) becomes too long.
Yet another theme is that of cheating, which you won’t find mentioned in many TDD books. It’s an acknowledgment that the TDD workflow is a scaffold around which you can build your own rules. Once you’ve learned and practiced the strict version of TDD for a while, you can learn what cheats you can use to cut corners. What tests won’t provide much value in the long run? How can you speed up repetitive tests? So, a cheat is almost like saying you cut a corner in a way that wouldn’t be obvious to an observer if they came to look at your code tomorrow. Maybe, for example, you implement three tests at once, rather than one at a time.
In this second edition of the book, I have doubled down on teaching TDD over React features. Beyond updating the code samples to work with React 18, there are few usages of new React features. Instead, the tests have been vastly improved; they are simpler, smaller, and utilize custom Jest matchers (which are themselves test-driven). Readers of the first edition will notice that I’ve changed my approach to component mocks; this edition relies on module mocks via the jest.mock function. The book no longer teaches shallow rendering. There are other smaller changes too, such as the avoidance of the ReactTestUtils.Simulate module. Chapter organization has been improved too, with some of the earlier chapters split up and streamlined. I hope you’ll agree that this edition is leaps and bounds better than the first.
If you’re a React programmer, this book is for you. I aim to show you how TDD can improve your work.
If you’re already knowledgeable about TDD, I hope there’s still a lot you can learn from comparing your own process with mine.
If you don’t already know React, you will benefit from spending some time running through the Getting Started guide on the React website. That being said, TDD is a wonderful platform for explaining new technologies, and it’s entirely plausible that you’ll be able to pick up React simply by following this book.
Chapter 1, First Steps with Test-Driven Development, introduces Jest and the TDD cycle.
Chapter 2, Rendering Lists and Detail Views, uses the TDD cycle to build a simple page displaying customer information.
Chapter 3, Refactoring the Test Suite, introduces some of the basic ways in which you can simplify tests.
Chapter 4, Test-Driving Data Input with React, covers using React component state to manage the display and saving of text input fields.
Chapter 5, Adding Complex Form Interactions, looks at a more complex form setup with dropdowns and radio buttons.
Chapter 6, Exploring Test Doubles, introduces various types of test doubles that are necessary for testing collaborating objects, and how to use them to test-drive form submission.
Chapter 7, Testing useEffect and Mocking Components, looks at using test doubles to fetch data when components are mounted, and how to use module mocks to block that behavior when testing parent components.
Chapter 8, Building an Application Component, ties everything together with a “root” component that threads together a user journey.
Chapter 9, Form Validation, continues with form building by adding client- and server-side validation and adding an indicator to show that data is being submitted.
Chapter 10, Filtering and Searching Data, shows how to build a search component with some complex interaction requirements, in addition to complex fetch request requirements.
Chapter 11, Test-Driving React Router, introduces the React Router library to simplify navigation within our user journeys.
Chapter 12, Test-Driving Redux, introduces Redux into our application.
Chapter 13, Test-Driving GraphQL, introduces the Relay library to communicate with a GraphQL endpoint that’s provided by our application backend.
Chapter 14, Building a Logo Interpreter, introduces a fun application that we will begin to explore by building out features across both React components and Redux middleware: undo/redo, persisting state across browser sessions with the LocalStorage API, and programmatically managing field focus.
Chapter 15, Adding Animation, covers adding animations to our application using the browser requestAnimationFrame API, all with a test-driven approach.
Chapter 16, Working with WebSockets, adds support for WebSocket communication with our application backend.
Chapter 17, Writing Your First Cucumber Test, introduces Cucumber and Puppeteer, which we will use to build BDD tests for existing functionality.
Chapter 18, Adding Features Guided by Cucumber Tests, integrates acceptance testing into our development process by first building BDD tests with Cucumber, before dropping down to unit tests.
Chapter 19, Understanding TDD in the Wider Testing Landscape, finishes the book by looking at how what you’ve learned fits in with other test and quality practices.
There are two ways to read this book.
The first is to use it as a reference when you are faced with specific testing challenges. Use the index to find what you’re after and move to that page.
The second, and the one I’d recommend starting with, is to follow the walk-throughs step by step, building your own code base as you go along. The companion GitHub repository has a directory for each chapter (such as Chapter01) and then, within that, three directories:
You will need to be at least a little proficient with Git; a basic understanding of the branch, checkout, clone, commit, diff, and merge commands should be sufficient.
Take a look at the README.md file in the GitHub repository for more information and instructions on working with the code base.
If you are using the digital version of this book, we advise you to type the code yourself or access the code from the book’s GitHub repository (a link is available in the next section). Doing so will help you avoid any potential errors related to the copying and pasting of code.
You can download the example code files for this book from GitHub at http://github.com/packtPublishing/Mastering-React-Test-Driven-Development-Second-Edition/. If there’s an update to the code, it will be updated in the GitHub repository.
We also have other code bundles from our rich catalog of books and videos available at https://github.com/PacktPublishing/. Check them out!
We also provide a PDF file that has color images of the screenshots and diagrams used in this book. You can download it here: https://packt.link/5dqQx.
There are a number of text conventions used throughout this book.
Code in text: Indicates code words in text, database table names, folder names, filenames, file extensions, pathnames, dummy URLs, user input, and Twitter handles. Here is an example: “In the first test, change the word appendChild to replaceChildren.”
Bold: Indicates a new term, an important word, or words that you see onscreen. For instance, words in menus or dialog boxes appear in bold. Here is an example: “The presenter clicks the Start sharing button.”
Tips or important notes
Appear like this.
A block of code is set as follows:
it("renders the customer first name", () => { const customer = { firstName: "Ashley" }; render(<Appointment customer={customer} />); expect(document.body.textContent).toContain("Ashley");});
There are two important things to know about the code snippets that appear in this book.
The first is that some code samples show modifications to existing sections of code. When this happens, the changed lines appear in bold, and the other lines are simply there to provide context:
export const Appointment = ({ customer }) => ( <div>{customer.firstName}</div>);
The second is that, often, some code samples will skip lines in order to keep the context clear. When this occurs, you’ll see this marked by a line with three dots:
if (!anyErrors(validationResult)) {
...
} else {
setValidationErrors(validationResult);
}
Sometimes, this happens for function parameters too:
if (!anyErrors(validationResult)) {
setSubmitting(true);
const result = await window.fetch(...);
setSubmitting(false);
...
}
Any command-line input or output is written as follows:
npx relay-compiler
The book almost exclusively uses arrow functions for defining functions. The only exceptions are when we write generator functions, which must use the standard function’s syntax. If you’re not familiar with arrow functions, they look like this, which defines a single-argument function named inc:
const inc = arg => arg + 1;
They can appear on one line or be broken into two:
const inc = arg => arg + 1;
Functions that have more than one argument have the arguments wrapped in brackets:
const add = (a, b) => a + b;
If a function has multiple statements, then the body is wrapped in curly braces:
const dailyTimeSlots = (salonOpensAt, salonClosesAt) => {
...
return timeIncrements(totalSlots, startTime, increment);};
If the function returns an object, then that object must be wrapped in brackets so that the runtime doesn’t think it’s executing a block:
setAppointment(appointment => ({ ...appointment, [name]: value });
This book makes liberal use of destructuring techniques to keep the code base as concise as possible. As an example, object destructuring generally happens for function parameters:
const handleSelectBoxChange = (
{ target: { value, name } }
) => {
...
};
This is equivalent to saying this:
const handleSelectBoxChange = (event) => {
const target = event.target;
const value = target.value;
const name = target.name;
...
};
Return values can also be destructured in the same way:
const [customer, setCustomer] = useState({});
This is equivalent to the following:
const customerState = useState({});
const customer = customerState[0];
const setCustomer = customerState[1];
Feedback from our readers is always welcome.
General feedback: If you have questions about any aspect of this book, email us at customercare@packtpub.com and mention the book title in the subject of your message.
Errata: Although we have taken every care to ensure the accuracy of our content, mistakes do happen. If you have found a mistake in this book, we would be grateful if you would report this to us. Please visit www.packtpub.com/support/errata and fill in the form.
Piracy: If you come across any illegal copies of our works in any form on the internet, we would be grateful if you would provide us with the location address or website name. Please contact us at copyright@packt.com with a link to the material.
If you are interested in becoming an author: If there is a topic that you have expertise in and you are interested in either writing or contributing to a book, please visit authors.packtpub.com.
Once you’ve read Mastering React Test-Driven Development, we’d love to hear your thoughts! Please click here to go straight to the Amazon review page for this book and share your feedback.
Your review is important to us and the tech community and will help us make sure we’re delivering excellent quality content.
Part 1 introduces all of the basic techniques you’ll need to test-drive React applications. As you build more of the application, you will create a set of library functions that help to simplify and accelerate your testing. The goal is to give you both theoretical and practical advice that will help you apply the test-driven development workflow to your daily work.
This part includes the following chapters:
This book is a walk-through of building React applications using a test-driven approach. We’ll touch on many different parts of the React experience, including building forms, composing interfaces, and animating elements. Perhaps more importantly, we’ll do that all while learning a whole range of testing techniques.
You might have already used a React testing library such as React Testing Library or Enzyme, but this book doesn’t use them. Instead, we’ll be working from first principles: building up our own set of test functions based directly on our needs. That way, we can focus on the key ingredients that make up all great test suites. These ingredients—ideas such as super-small tests, test doubles, and factory methods—are decades old and apply across all modern programming languages and runtime environments. That’s why this book doesn’t use a testing library; there’s really no need. What you’ll learn will be useful to you no matter which testing libraries you use.
On the other hand, Test-Driven Development (TDD) is an effective technique for learning new frameworks and libraries. That makes this a very well-suited book for React and its ecosystem. This book will allow you to explore React in a way that you may not have experienced before as well as to make use of React Router and Redux and build out a GraphQL interface.
If you’re new to the TDD process, you might find it a bit heavy-handed. It is a meticulous and disciplined style of developing software. You’ll wonder why we’re going to such Herculean efforts to build an application. For those that master it, there is tremendous value to be gained in specifying our software in this way, as follows:
You’ll soon start recognizing the higher level of trust and confidence you have in the code you’re working on. If you’re anything like us, you’ll get hooked on that feeling and find it hard to work without it.
Parts 1 and 2 of this book involve building an appointment system for a hair salon – nothing too revolutionary, but as sample applications go, it offers plenty of scope. We’ll get started with that in this chapter. Parts 3 and 4 use an entirely different application: a logo interpreter. Building that offers a fun way to explore more of the React landscape.
The following topics will be covered in this chapter:
By the end of the chapter, you’ll have a good idea of what the TDD process looks like when building out a simple React component. You’ll see how to write a test, how to make it pass, and how to refactor your work.
Later in this chapter, you’ll be required to install Node Package Manager (npm) together with a whole host of packages. You’ll want to ensure you have a machine capable of running the Node.js environment.
You’ll also need access to a terminal.
In addition, you should choose a good editor or Integrated Development Environment (IDE) to work with your code.
The code files for this chapter can be found at the following link: https://github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter01.
In this section, we’ll assemble all of the necessary pieces that you’ll need to write a React application with TDD.
You may have come across the create-react-app package, which many people use to create an initial React project, but we won’t be using that. The very first TDD principle you’re going to learn is You Ain’t Gonna Need It (YAGNI). The create-react-app package adds a whole bunch of boilerplate that isn’t relevant to what we’re doing here—things such as a favicon.ico file, a sample logo, and CSS files. While these are undoubtedly useful, the basic idea behind YAGNI is that if it doesn’t meet a needed specification, then it doesn’t go in.
The thinking behind YAGNI is that anything unnecessary is simply technical debt – it’s stuff that’s just sitting there, unused, getting in your way.
Once you see how easy it is to start a React project from scratch, you won’t ever use create-react-app again!
In the following subsections, we’ll install NPM, Jest, React, and Babel.
Following the TDD process means running tests frequently—very frequently. Tests are run on the command line using the npm test command. So, let’s start by getting npm installed.
You can find out if you already have it installed on your machine by opening a terminal window (or Command Prompt if you’re on Windows) and typing the following command:
npm -v
If the command isn’t found, head on over to the Node.js website at https://nodejs.org for installation instructions.
If you’ve already got npm installed, we recommend you ensure you’re on the latest version. You can do this on the command line by typing the following command:
npm install npm@latest -g
Now you’re all set. You can use the npm command to create your project.
Now that With npm installed, we can create our project by performing the following steps:
Editing the package.json file by hand
Don’t worry if you miss the prompt for the test command while you work through the instructions; you can set it afterwards by adding "test": "jest" to the scripts section of the generated package.json file.
added 325 packages, and audited 326 packages in 24s
Alternatives to Jest
The TDD practices you’ll study in this book will work for a wide variety of test runners, not just Jest. An example is the Mocha test runner. If you’re interested in using Mocha with this book, take a look at the guidance at https://reacttdd.com/migrating-from-jest-to-mocha.
Although we’ve just started, it’s time to commit what you’ve done. The TDD process offers natural stopping points to commit – each time you see a new test pass, you can commit. This way, your repository will fill up with lots of tiny commits. You might not be used to that—you may be more of a “one commit per day” person. This is a great opportunity to try something new!
Committing early and often simplifies commit messages. If you have just one test in a commit, then you can use the test description as your commit message. No thinking is required. Plus, having a detailed commit history helps you backtrack if you change your mind about a particular implementation.
So, get used to typing git commit when you’ve got a passing test.
As you approach the end of a feature’s development, you can use git rebase to squash your commits so that your Git history is kept tidy.
Assuming you’re using Git to keep track of your work, go ahead and enter the following commands to commit what you’ve done so far:
git init
echo "node_modules" > .gitignore
git add .
git commit -m "Blank project with Jest dependency"
You’ve now “banked” that change and you can safely put it out of your mind and move on to the following two dependencies, which are React and Babel.
Let’s install React. That’s two packages that can be installed with this next command:
npm install --save react react-dom
Next, we need Babel, which transpiles a few different things for us: React’s JavaScript Syntax Extension (JSX) templating syntax, module mocks (which we’ll meet in Chapter 7, Testing useEffect and Mocking Components), and various draft ECMAScript constructs that we’ll use.
Important note
The following information is accurate for Babel 7. If you’re using a later version, you may need to adjust the installation instructions accordingly.
Now, Jest already includes Babel—for the aforementioned module mocks—so we just need to install presets and plugins as follows:
npm install --save-dev @babel/preset-env @babel/preset-react
npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime
A Babel preset is a set of plugins. Each plugin enables a specific feature of the ECMAScript standards or a preprocessor such as JSX.
Configuring Babel
The env preset should usually be configured with target execution environments. It’s not necessary for the purposes of this book. See the Further reading section at the end of this chapter for more information.
We need to enable the packages we’ve just installed. Create a new file, .babelrc, and add the following code:
{
"presets": ["@babel/env", "@babel/react"],
"plugins": ["@babel/transform-runtime"]
}
Both Babel and React are now ready for use.
Tip
You may wish to commit your source code to Git at this point.
In this section, you’ve installed NPM, primed your new Git repository, and you’ve installed the package dependencies you’ll need to build your React app with TDD. You’re all set to write some tests.
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
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!
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"
);
});
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 { Appointment } from "../src/Appointment";
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
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:
import { act } from "react-dom/test-utils";
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.
{
...,
"jest": {
"testEnvironment": "jsdom",
"globals": {
"IS_REACT_ACT_ENVIRONMENT": true
}
}
}
> 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:
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"
);
});
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.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
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"
);
});
It’s time to bring the skipped test back in by removing .skip from the function name.
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"
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.
Now that you’ve got a green test, it’s time to refactor your work. Refactoring is the process of adjusting your code’s structure without changing its functionality. It’s crucial for keeping a code base in a fit, maintainable state.
Sadly, the refactoring step is the step that always gets forgotten. The impulse is to rush straight into the next feature. We can’t stress how important it is to take time to simply stop and stare at your code and think about ways to improve it. Practicing your refactoring skills is a sure-fire way to level up as a developer.
The adage “more haste; less speed” applies to coding just as it does in life. If you make a habit of skipping the refactoring phase, your code quality will likely deteriorate over time, making it harder to work with and therefore slower to build new features.
The TDD cycle helps you build good personal discipline and habits, such as consistently refactoring. It might take more effort upfront, but you will reap the rewards of a code base that remains maintainable as it ages.
Don’t Repeat Yourself
Test code needs as much care and attention as production code. The number one principle you’ll be relying on when refactoring your tests is Don’t Repeat Yourself (DRY). Drying up tests is a phrase all TDDers repeat often.
The key point is that you want your tests to be as concise as possible. When you see repeated code that exists in multiple tests, it’s a great indication that you can pull that repeated code out. There are a few different ways to do that, and we’ll cover just a couple in this chapter.
You will see further techniques for drying up tests in Chapter 3, Refactoring the Test Suite.
When tests contain identical setup instructions, we can promote those instructions into a shared beforeEach block. The code in this block is executed before each test.
Both of our tests use the same two variables: container and customer. The first one of these, container, is initialized identically in each test. That makes it a good candidate for a beforeEach block.
Perform the following steps to introduce your first beforeEach block:
let container;
beforeEach(() => {
container = document.createElement("div");
document.body.replaceChildren(container);
});
Use of let instead of const
Be careful when you use let definitions within the describe scope. These variables are not cleared by default between each test execution, and that shared state will affect the outcome of each test. A good rule of thumb is that any variable you declare in the describe scope should be assigned to a new value in a corresponding beforeEach block, or in the first part of each test, just as we’ve done here.
For a more detailed look at the use of let in test suites, head to https://reacttdd.com/use-of-let.
In Chapter 3, Refactoring the Test Suite, we’ll look at a method for sharing this setup code between multiple test suites.
The call to render is the same in both tests. It’s also quite lengthy given that it’s wrapped in a call to act. It makes sense to extract this entire operation and give it a more meaningful name.
Rather than pull it out as is, we can create a new function that takes the Appointment component as its parameter. The explanation for why this is useful will come after, but now let’s perform the following steps:
const render = component =>
act(() =>
ReactDOM.createRoot(container).render(component)
);
render(<Appointment customer={customer} />);
it("renders the customer first name", () => {
const customer = { firstName: "Ashley" };
render(<Appointment customer={customer} />);
expect(document.body.textContent).toContain(
"Ashley"
);
});
Highlighting differences within your tests
The parts of a test that you want to highlight are the parts that differ between tests. Usually, some code remains the same (such as container and the steps needed to render a component) and some code differs (customer in this example). Do your best to hide away whatever is the same and highlight what differs. That way, it makes it obvious what a test is specifically testing.
This section has covered a couple of simple ways of refactoring your code. As the book progresses, we’ll look at many different ways that both production source code and test code can be refactored.
Now that you’ve written a couple of tests, let’s step away from the keyboard and discuss what you’ve seen so far.
Your first test looks like the following example:
it("renders the customer first name", () => {
const customer = { firstName: "Ashley" };
render(<Appointment customer={customer} />);
expect(document.body.textContent).toContain("Ashley");
});
This is concise and clearly readable.
A good test has the following three distinct sections:
This is so well understood that it is called the Arrange, Act, Assert (AAA) pattern, and all of the tests in this book follow this pattern.
A great test is not just good but is also the following:
In the remainder of this section, we’ll discuss the TDD cycle, which you’ve already used, and also how to set up your development environment for easy TDD.
TDD, at its heart, is the red, green, refactor cycle that we’ve just seen.
Figure 1.1 – The TDD cycle
The steps of the TDD cycle are:
That’s all there is to it. You’ve already seen this cycle in action in the preceding two sections, and we’ll continue to use it throughout the rest of the book.
Think about the effort you’ve put into this book so far. What actions have you been doing the most? They are the following:
Make sure you can perform these actions quickly.
For a start, you should use split-screen functionality in your editor. If you aren’t already, take this opportunity to learn how to do it. Load your production module on one side and the corresponding unit test file on the other.
Here’s a picture of our setup; we use nvim and tmux:
Figure 1.2 – A typical TDD setup running tmux and vim in a terminal
You can see that we also have a little test window at the bottom for showing test output.
Jest can also watch your files and auto-run tests when they change. To enable this, change the test command in package.json to jest --watchAll. This reruns all of your tests when it detects any changes.
Watching files for changes
Jest’s watch mode has an option to run only the tests in files that have changed, but since your React app will be composed of many different files, each of which are interconnected, it’s better to run everything as breakages can happen in many modules.
Tests act like a safety harness in our learning; we can build little blocks of understanding, building on top of each other, up and up to ever-greater heights, without fear of falling.
In this chapter, you’ve learned a lot about the TDD experience.
To begin with, you set up a React project from scratch, pulling in only the dependencies you need to get things running. You’ve written two tests using Jest’s describe, it, and beforeEach functions. You discovered the act helper, which ensures all React rendering has been completed before your test expectations execute.
You’ve also seen plenty of testing ideas. Most importantly, you’ve practiced TDD’s red-green-refactor cycle. You’ve also used triangulation and you learned about the Arrange, Act, Assert pattern.
And we threw in a couple of design principles for good measure: DRY and YAGNI.
While this is a great start, the journey has only just begun. In the following chapter, we’ll test drive a more complex component.
Take a look at the Babel web page to discover how to correctly configure the Babel env preset. This is important for real-world applications, but we skipped over it in this chapter. You can find it at the following link:
https://babeljs.io/docs/en/babel-preset-env.
React’s act function was introduced in React 17 and has seen updates in React 18. It is deceptively complex. See this blog post for some more discussion on how this function is used at the following link: https://reacttdd.com/understanding-act.
This book doesn’t make much use of Jest’s watch functionality. In recent versions of Jest, this has seen some interesting updates, such as the ability to choose which files to watch. If you find rerunning tests a struggle, you might want to try it out. You can find more information at the following link: https://jestjs.io/docs/en/cli#watch.
The previous chapter introduced the core TDD cycle: red, green, refactor. You had the chance to try it out with two simple tests. Now, it’s time to apply that to a bigger React component.
At the moment, your application displays just a single item of data: the customer’s name. In this chapter, you’ll extend it so that you have a view of all appointments for the current day. You’ll be able to choose a time slot and see the details for the appointment at that time. We will start this chapter by sketching a mock-up to help us plan how we’ll build out the component. Then, we’ll begin implementing a list view and showing appointment details.
Once we’ve got the component in good shape, we’ll build the entry point with webpack and then run the application in order to do some manual testing.
The following topics will be covered in this chapter:
By the end of this chapter, you’ll have written a decent-sized React component using the TDD process you’ve already learned. You’ll also have seen the app running for the first time.
The code files for this chapter can be found at https://github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter02.
Let’s start with a little more up-front design. We’ve got an Appointment component that takes an appointment and displays it. We will build an AppointmentsDayView component around it that takes an array of appointment objects and displays them as a list. It will also display a single Appointment: the appointment that is currently selected. To select an appointment, the user simply clicks on the time of day that they’re interested in.
Figure 2.1 – A mock-up of our appointment system UI
Up-front design
When you’re using TDD to build new features, it’s important to do a little up-front design so that you have a general idea of the direction your implementation needs to take.
That’s all the design we need for now; let’s jump right in and build the new AppointmentsDayView component.
In this section, we’ll create the basic form of AppointmentsDayView: a list of appointment times for the day. We won’t build any interactive behavior for it just yet.
We’ll add our new component into the same file we’ve been using already because so far there’s not much code in there. Perform the following steps:
Placing components
We don’t always need a new file for each component, particularly when the components are short functional components, such as our Appointment component (a one-line function). It can help to group related components or small sub-trees of components in one place.
describe("AppointmentsDayView", () => {
let container;
beforeEach(() => {
container = document.createElement("div");
document.body.replaceChildren(container);
});
const render = (component) =>
act(() =>
ReactDOM.createRoot(container).render(component)
);
it("renders a div with the right id", () => {
render(<AppointmentsDayView appointments={[]} />);
expect(
document.querySelector(
"div#appointmentsDayView"
)
).not.toBeNull();
});
});
Note
It isn’t usually necessary to wrap your component in a div with an ID or a class. We tend to do it when we have CSS that we want to attach to the entire group of HTML elements that will be rendered by the component, which, as you’ll see later, is the case for AppointmentsDayView.
This test uses the exact same render function from the first describe block as well as the same let container declaration and beforeEach block. In other words, we’ve introduced duplicated code. By duplicating code from our first test suite, we’re making a mess straight after cleaning up our code! Well, we’re allowed to do it when we’re in the first stage of the TDD cycle. Once we’ve got the test passing, we can think about the right structure for the code.
FAIL test/Appointment.test.js
Appointment
✓ renders the customer first name (18ms)
✓ renders another customer first name (2ms)
AppointmentsDayView
✕ renders a div with the right id (7ms)
● AppointmentsDayView › renders a div with the right id
ReferenceError: AppointmentsDayView is not defined
Let’s work on getting this test to pass by performing the following steps:
import {
Appointment,
AppointmentsDayView,
} from "../src/Appointment";
export const AppointmentsDayView = () => {};
● AppointmentsDayView › renders a div with the right id
expect(received).not.toBeNull()
export const AppointmentsDayView = () => (
<div id="appointmentsDayView"></div>
);
it("renders an ol element to display appointments", () => {
render(<AppointmentsDayView appointments={[]} />);
const listElement = document.querySelector("ol");
expect(listElement).not.toBeNull();
});
● AppointmentsDayView › renders an ol element to display appointments
expect(received).not.toBeNull()
Received: null
export const AppointmentsDayView = () => (
<div id="appointmentsDayView">
<ol />
</div>
);
it("renders an li for each appointment", () => {
const today = new Date();
const twoAppointments = [
{ startsAt: today.setHours(12, 0) },
{ startsAt: today.setHours(13, 0) },
];
render(
<AppointmentsDayView
appointments={twoAppointments}
/>
);
const listChildren =
document.querySelectorAll("ol > li");
expect(listChildren).toHaveLength(2);
});
Testing dates and times
In the test, the today constant is defined to be new Date(). Each of the two records then uses this as a base date. Whenever we’re dealing with dates, it’s important that we base all events on the same moment in time, rather than asking the system for the current time more than once. Doing that is a subtle bug waiting to happen.
● AppointmentsDayView › renders an li for each appointment
expect(received).toHaveLength(expected)
Expected length: 2
Received length: 0
Received object: []
export const AppointmentsDayView = (
{ appointments }
) => (
<div id="appointmentsDayView">
<ol>
{appointments.map(() => (
<li />
))}
</ol>
</div>
);
Ignoring unused function arguments
The map function will provide an appointment argument to the function passed to it. Since we don’t use the argument (yet), we don’t need to mention it in the function signature—we can just pretend that our function has no arguments instead, hence the empty brackets. Don’t worry, we’ll need the argument for a subsequent test, and we’ll add it in then.
console.error
Warning: Each child in a list should have a unique "key" prop.
Check the render method of AppointmentsDayView.
...
PASS test/Appointment.test.js
Appointment
✓ renders the customer first name (19ms)
✓ renders another customer first name (2ms)
AppointmentsDayView
✓ renders a div with the right id (7ms)
✓ renders an ol element to display appointments (16ms)
✓ renders an li for each appointment (16ms)
<ol>
{appointments.map(appointment => (
<li key={appointment.startsAt} />
))}
</ol>
Testing keys
There’s no easy way for us to test key values in React. To do it, we’d need to rely on internal React properties, which would introduce a risk of tests breaking if the React team were to ever change those properties.
The best we can do is set a key to get rid of this warning message. In an ideal world, we’d have a test that uses the startsAt timestamp for each li key. Let’s just imagine that we have that test in place.
This section has covered how to render the basic structure of a list and its list items. Next, it’s time to fill in those items.
In this section, you’ll add a test that uses an array of example appointments to specify that the list items should show the time of each appointment, and then you’ll use that test to support the implementation.
it("renders the time of each appointment", () => {
const today = new Date();
const twoAppointments = [
{ startsAt: today.setHours(12, 0) },
{ startsAt: today.setHours(13, 0) },
];
render(
<AppointmentsDayView
appointments={twoAppointments}
/>
);
const listChildren =
document.querySelectorAll("li");
expect(listChildren[0].textContent).toEqual(
"12:00"
);
expect(listChildren[1].textContent).toEqual(
"13:00"
);
});
Jest will show the following error:
● AppointmentsDayView › renders the time of each appointment
expect(received).toEqual(expected) // deep equality
Expected: "12:00"
Received: ""
The toEqual matcher
This matcher is a stricter version of toContain. The expectation only passes if the text content is an exact match. In this case, we think it makes sense to use toEqual. However, it’s often best to be as loose as possible with your expectations. Tight expectations have a habit of breaking any time you make the slightest change to your code base.
const appointmentTimeOfDay = (startsAt) => {
const [h, m] = new Date(startsAt)
.toTimeString()
.split(":");
return `${h}:${m}`;
}
Understanding syntax
This function uses destructuring assignment and template literals, which are language features that you can use to keep your functions concise.
Having good unit tests can help teach advanced language syntax. If we’re ever unsure about what a function does, we can look up the tests that will help us figure it out.
<ol>
{appointments.map(appointment => (
<li key={appointment.startsAt}>
{appointmentTimeOfDay(appointment.startsAt)}
</li>
))}
</ol>
PASS test/Appointment.test.js
Appointment
✓ renders the customer first name (19ms)
✓ renders another customer first name (2ms)
AppointmentsDayView
✓ renders a div with the right id (7ms)
✓ renders an ol element to display appointments (16ms)
✓ renders an li for each appointment (6ms)
✓ renders the time of each appointment (3ms)
This is a great chance to refactor. The last two AppointmentsDayView tests use the same twoAppointments prop value. This definition, and the today constant, can be lifted out into the describe scope, the same way we did with customer in the Appointment tests. This time, however, it can remain as const declarations as they never change.
That’s it for this test. Next, it’s time to focus on adding click behavior.
Let’s add in some dynamic behavior to our page. We’ll make each of the list items a link that the user can click on to view that appointment.
Thinking through our design a little, there are a few pieces we’ll need:
When we test React actions, we do it by observing the consequences of those actions. In this case, we can click on a button and then check that its corresponding appointment is now rendered on the screen.
We’ll break this section into two parts: first, we’ll specify how the component should initially appear, and second, we’ll handle a click event for changing the content.
Let’s start by asserting that each li element has a button element:
it("initially shows a message saying there are no appointments today", () => {
render(<AppointmentsDayView appointments={[]} />);
expect(document.body.textContent).toContain(
"There are no appointments scheduled for today."
);
});
return (
<div id="appointmentsDayView">
...
<p>There are no appointments scheduled for today.</p>
</div>
);
it("selects the first appointment by default", () => {
render(
<AppointmentsDayView
appointments={twoAppointments}
/>
);
expect(document.body.textContent).toContain(
"Ashley"
);
});
const twoAppointments = [
{
startsAt: today.setHours(12, 0),
customer: { firstName: "Ashley" },
},
{
startsAt: today.setHours(13, 0),
customer: { firstName: "Jordan" },
},
];
<div id="appointmentsDayView">
...
{appointments.length === 0 ? (
<p>There are no appointments scheduled for today.</p>
) : (
<Appointment {...appointments[0]} />
)}
</div>
Now we’re ready to let the user make a selection.
We’re about to add state to our component. The component will show a button for each appointment. When the button is clicked, the component stores the array index of the appointment that it refers to. To do that, we’ll use the useState hook.
What are hooks?
Hooks are a feature of React that manages various non-rendering related operations. The useState hook stores data across multiple renders of your function. The call to useState returns both the current value in storage and a setter function that allows it to be set.
If you’re new to hooks, check out the Further reading section at the end of this chapter. Alternatively, you could just follow along and see how much you can pick up just by reading the tests!
We’ll start by asserting that each li element has a button element:
it("has a button element in each li", () => {
render(
<AppointmentsDayView
appointments={twoAppointments}
/>
);
const buttons =
document.querySelectorAll("li > button");
expect(buttons).toHaveLength(2);
expect(buttons[0].type).toEqual("button");
});
Testing element positioning
We don’t need to be pedantic about checking the content or placement of the button element within its parent. For example, this test would pass if we put an empty button child at the end of li. But, thankfully, doing the right thing is just as simple as doing the wrong thing, so we can opt to do the right thing instead. All we need to do to make this test pass is wrap the existing content in the new tag.
...
<li key={appointment.startsAt}>
<button type="button">
{appointmentTimeOfDay(appointment.startsAt)}
</button>
</li>
...
it("renders another appointment when selected", () => {
render(
<AppointmentsDayView
appointments={twoAppointments}
/>
);
const button =
document.querySelectorAll("button")[1];
act(() => button.click());
expect(document.body.textContent).toContain(
"Jordan"
);
});
Synthetic events and Simulate
An alternative to using the click function is to use the Simulate namespace from React’s test utilities to raise a synthetic event. While the interface for using Simulate is somewhat simpler than the DOM API for raising events, it’s also unnecessary for testing. There’s no need to use extra APIs when the DOM API will suffice. Perhaps more importantly, we also want our tests to reflect the real browser environment as much as possible.
● AppointmentsDayView › renders appointment when selected
expect(received).toContain(expected)
Expected substring: "Jordan"
Received string: "12:0013:00Ashley"
Notice the full text in the received string. We’re getting the text content of the list too because we’ve used document.body.textContent in our expectation rather than something more specific.
Specificity of expectations
Don’t be too bothered about where the customer’s name appears on the screen. Testing document.body.textContent is like saying “I want this text to appear somewhere, but I don’t care where.” Often, this is enough for a test. Later on, we’ll see techniques for expecting text in specific places.
There’s a lot we now need to get in place in order to make the test pass. We need to introduce state and we need to add the handler. Perform the following steps:
import React, { useState } from "react";
export const AppointmentsDayView = (
{ appointments }
) => {
return (
<div id="appointmentsDayView">
...
</div>
);
};
const [selectedAppointment, setSelectedAppointment] =
useState(0);
<div id="appointmentsDayView">
...
<Appointment
{...appointments[selectedAppointment]}
/>
</div>
{appointments.map((appointment, i) => (
<li key={appointment.startsAt}>
<button type="button">
{appointmentTimeOfDay(appointment.startsAt)}
</button>
</li>
))}
<button
type="button"
onClick={() => setSelectedAppointment(i)}
>
PASS test/Appointment.test.js
Appointment
✓ renders the customer first name (18ms)
✓ renders another customer first name (2ms)
AppointmentsDayView
✓ renders a div with the right id (7ms)
✓ renders multiple appointments in an ol element (16ms)
✓ renders each appointment in an li (4ms)
✓ initially shows a message saying there are no appointments today (6ms)
✓ selects the first element by default (2ms)
✓ has a button element in each li (2ms)
✓ renders another appointment when selected (3ms)
We’ve covered a lot of detail in this section, starting with specifying the initial state of the view through to adding a button element and handling its onClick event.
We now have enough functionality that it makes sense to try it out and see where we’re at.
The words manual testing should strike fear into the heart of every TDDer because it takes up so much time. Avoid it when you can. Of course, we can’t avoid it entirely – when we’re done with a complete feature, we need to give it a once-over to check we’ve done the right thing.
As it stands, we can’t yet run our app. To do that, we’ll need to add an entry point and then use webpack to bundle our code.
React applications are composed of a hierarchy of components that are rendered at the root. Our application entry point should render this root component.
We tend to not test-drive entry points because any test that loads our entire application can become quite brittle as we add more and more dependencies into it. In Part 4, Behavior-Driven Development with Cucumber, we’ll look at using Cucumber tests to write some tests that will cover the entry point.
Since we aren’t test-driving it, we follow a couple of general rules:
Before we run our app, we’ll need some sample data. Create a file named src/sampleData.js and fill it with the following code:
const today = new Date();
const at = (hours) => today.setHours(hours, 0);
export const sampleAppointments = [
{ startsAt: at(9), customer: { firstName: "Charlie" } },
{ startsAt: at(10), customer: { firstName: "Frankie" } },
{ startsAt: at(11), customer: { firstName: "Casey" } },
{ startsAt: at(12), customer: { firstName: "Ashley" } },
{ startsAt: at(13), customer: { firstName: "Jordan" } },
{ startsAt: at(14), customer: { firstName: "Jay" } },
{ startsAt: at(15), customer: { firstName: "Alex" } },
{ startsAt: at(16), customer: { firstName: "Jules" } },
{ startsAt: at(17), customer: { firstName: "Stevie" } },
];
Important note
The Chapter02/Complete directory in the GitHub repository contains a more complete set of sample data.
This list also doesn’t need to be test-driven for the following couple of reasons:
Tip
TDD is often a pragmatic choice. Sometimes, not test-driving is the right thing to do.
Create a new file, src/index.js, and enter the following code:
import React from "react";
import ReactDOM from "react-dom/client";
import { AppointmentsDayView } from "./Appointment";
import { sampleAppointments } from "./sampleData";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<AppointmentsDayView appointments={sampleAppointments} />
);
Jest uses Babel to transpile all our code when it’s run in the test environment. But what about when we’re serving our code via our website? Jest won’t be able to help us there.
That’s where webpack comes in, and we can introduce it now to help us do a quick manual test as follows:
npm install --save-dev webpack webpack-cli babel-loader
"build": "webpack",
const path = require("path");
const webpack = require("webpack");
module.exports = {
mode: "development",
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
loader: "babel-loader",
},
],
},
};
This configuration works for webpack in development mode. Consult the webpack documentation for information on setting up production builds.
mkdir dist
touch dist/index.html
<!DOCTYPE html>
<html>
<head>
<title>Appointments</title>
</head>
<body>
<div id="root"></div>
<script src="main.js"></script>
</body>
</html>
npm run build
You should see output such as the following:
modules by path ./src/*.js 2.56 KiB
./src/index.js 321 bytes [built] [code generated]
./src/Appointment.js 1.54 KiB [built] [code generated]
./src/sampleData.js 724 bytes [built] [code generated]
webpack 5.65.0 compiled successfully in 1045 ms
The following screenshot shows the application once the Exercises are completed, with added CSS and extended sample data. To include the CSS, you’ll need to pull dist/index.html and dist/styles.css from the Chapter02/Complete directory.
Figure 2.2 – The application so far
Before you commit your code into Git...
Make sure to add dist/main.js to your .gitignore file as follows:
echo "dist/main.js" >> .gitignore
The main.js file is generated by webpack, and as with most generated files, you shouldn’t check it in.
You may also want to add README.md at this point to remind yourself how to run tests and how to build the application.
You’ve now seen how to put TDD aside while you created an entry point: since the entry point is small and unlikely to change frequently, we’ve opted not to test-drive it.
In this chapter, you’ve been able to practice the TDD cycle a few times and get a feel for how a feature can be built out using tests as a guide.
We started by designing a quick mock-up that helped us decide our course of action. We have built a container component (AppointmentsDayView) that displayed a list of appointment times, with the ability to display a single Appointment component depending on which appointment time was clicked.
We then proceeded to get a basic list structure in place, then extended it to show the initial Appointment component, and then finally added the onClick behavior.
This testing strategy, of starting with the basic structure, followed by the initial view, and finishing with the event behavior, is a typical strategy for testing components.
We’ve only got a little part of the way to fully building our application. The first few tests of any application are always the hardest and take the longest to write. We are now over that hurdle, so we’ll move quicker from here onward.
Hooks are a relatively recent addition to React. Traditionally, React used classes for building components with state. For an overview of how hooks work, take a look at React’s own comprehensive documentation at the following link:
At this point, you’ve written a handful of tests. Although they may seem simple enough already, they can be simpler.
It’s extremely important to build a maintainable test suite: one that is quick and painless to build and adapt. One way to roughly gauge maintainability is to look at the number of lines of code in each test. To give some comparison to what you’ve seen so far, in the Ruby language, a test with more than three lines is considered a long test!
This chapter will take a look at some of the ways you can make your test suite more concise. We’ll do that by extracting common code into a module that can be reused across all your test suites. We’ll also create a custom Jest matcher.
When is the right time to pull out reusable code?
So far, you’ve written one module with two test suites within it. It’s arguably too early to be looking for opportunities to extract duplicated code. Outside of an educational setting, you may wish to wait until the third or fourth test suite before you pounce on any duplication.
The following topics will be covered in this chapter:
By the end of the chapter, you’ll have learned how to approach your test suite with a critical eye for maintainability.
The code files for this chapter can be found here: https://github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter03.
In this section, we will extract a module that initializes a unique DOM container element for each test. Then, we’ll build a render function that uses this container element.
The two test suites we’ve built both have the same beforeEach block that runs before each test:
let container;
beforeEach(() => {
container = document.createElement("div");
document.body.replaceChildren(container);
});
Wouldn’t it be great if we could somehow tell Jest that any test suite that is testing a React component should always use this beforeEach block and make the container variable available to our tests?
Here, we will extract a new module that exports two things: the container variable and the initializeReactContainer function. This won’t save us any typing, but it will hide the pesky let declaration and give a descriptive name to the call to createElement.
The importance of small functions with descriptive names
Often, it’s helpful to pull out functions that contain just a single line of code. The benefit is that you can then give it a descriptive name that serves as a comment as to what that line of code does. This is preferable to using an actual comment because the name travels with you wherever you use the code.
In this case, the call to document.createElement could be confusing to a future maintainer of your software. Imagine that it is someone who has never done any unit testing of React code. They would be asking, “Why do the tests create a new DOM element for each and every test?” You can go some way to answer that by giving it a name, such as initializeReactContainer. It doesn’t offer a complete answer as to why it’s necessary, but it does allude to some notion of “initialization.”
Let’s go ahead and pull out this code:
export let container;
export const initializeReactContainer = () => {
container = document.createElement("div");
document.body.replaceChildren(container);
}
import {
initializeReactContainer,
container,
} from "./reactTestExtensions";
beforeEach(() => {
initializeReactContainer();
});
Now, how about continuing with the render function? Let’s move that into our new module. This time, it’s a straight lift and replace job:
export const render = (component) =>
act(() =>
ReactDOM.createRoot(container).render(component)
);
import ReactDOM from "react-dom/client";
import { act } from "react-dom/test-utils";
import {
initializeReactContainer,
render,
} from "./reactTestExtensions";
So far, we've extracted two functions. We have one more to do: the click function. However, we have one more “action” function that we can create: click. Let’s do that now:
export const click = (element) =>
act(() => element.click());
import {
initializeReactContainer,
container,
render,
click,
} from "./reactTestExtensions";
click(button);
Avoiding the act function in your test code
The act function causes a fair amount of clutter in tests, which doesn’t help in our quest for conciseness. Thankfully, we can push it out into our extensions module and be done with it.
Remember the Arrange-Act-Assert pattern that our tests should always follow? Well, we’ve now extracted everything we can from the Arrange and Act sections.
The approach we’ve taken here, of using an exported container variable, isn’t the only approach worth exploring. You could, for example, build a wrapper function for describe that automatically includes a beforeEach block and builds a container variable that’s accessible within the scope of that describe block. You could name it something like describeReactComponent.
An advantage of this approach is that it involves a lot less code – you won’t be dealing with all those imports, and you could get rid of your beforeEach block in the test suites. The downside is that it’s very clever, which is not always a good thing when it comes to maintainability. There’s something a bit magical about it that requires a certain level of prior knowledge.
That being said, if this approach appeals to you, I encourage you to try it out.
In the next section, we’ll start to tackle the Assert section of our tests.
In our tests so far, we’ve used a variety of matchers. These functions tack on to the end of the expect function call:
expect(appointmentTable()).not.toBeNull();
In this section, you’ll build a matcher using a test-driven approach to make sure it’s doing the right thing. You’ll learn about the Jest matcher API as you build your test suite.
You’ve seen quite a few matchers so far: toBeNull, toContain, toEqual, and toHaveLength. You’ve also seen how they can be negated with not.
Matchers are a powerful way of building expressive yet concise tests. You should take some time to learn all the matchers that Jest has to offer.
Jest matcher libraries
There are a lot of different matcher libraries available as npm packages. Although we won’t use them in this book (since we’re building everything up from first principles), you should make use of these libraries. See the Further reading section at the end of this chapter for a list of libraries that will be useful to you when testing React components.
Often, you’ll want to build matchers. There are at least a couple of occasions that will prompt you to do this:
The second point is an interesting one. If you’re writing the same expectations multiple times across multiple tests, you should treat it just like you would if it was repeated code in your production source code. You’d pull that out into a function. Here, the matcher serves the same purpose, except using a matcher instead of a function helps remind you that this line of code is a special statement of fact about your software: a specification.
One expectation per test
You should generally aim for just one expectation per test. "Future you" will thank you for keeping things simple! (In Chapter 5, Adding Complex Form Interactions, we’ll look at a situation where multiple expectations are beneficial.)
You might hear this guideline and be instantly horrified. You might be imagining an explosion of tiny tests. But if you’re ready to write matchers, you can aim for one expectation per test and still keep the number of tests down.
The matcher we’re going to build in this section is called toContainText. It will replace the following expectation:
expect(appointmentTable().textContent).toContain("Ashley");
It will replace it with the following form, which is slightly more readable:
expect(appointmentTable()).toContainText("Ashley");
Here’s what the output looks like on the terminal:
Figure 3.1 – The output of the toContainText matcher when it fails
Let’s get started:
import { toContainText } from "./toContainText";
describe("toContainText matcher", () => {
it("returns pass is true when text is found in the given DOM element", () => {
const domElement = {
textContent: "text to find"
};
const result = toContainText(
domElement,
"text to find"
);
expect(result.pass).toBe(true);
});
});
export const toContainText = (
received,
expectedText
) => ({
pass: true
});
it("return pass is false when the text is not found in the given DOM element", () => {
const domElement = { textContent: "" };
const result = toContainText(
domElement,
"text to find"
);
expect(result.pass).toBe(false);
});
export const toContainText = (
received,
expectedText
) => ({
pass: received.textContent.includes(expectedText)
});
it("returns a message that contains the source line if no match", () => {
const domElement = { textContent: "" };
const result = toContainText(
domElement,
"text to find"
);
expect(
stripTerminalColor(result.message())
).toContain(
`expect(element).toContainText("text to find")`
);
});
Understanding the message function
The requirements for the message function are complex. At a basic level, it is a helpful string that is displayed when the expectation fails. However, it’s not just a string – it’s a function that returns a string. This is a performance feature: the value of message does not need to be evaluated unless there is a failure. But even more complicated is the fact that the message should change, depending on whether the expectation was negated or not. If pass is false, then the message function should assume that the matcher was called in the “positive” sense – in other words, without a .not qualifier. But if pass is true, and the message function ends up being invoked, then it’s safe to assume that it was negated. We’ll need another test for this negated case, which comes a little later.
const stripTerminalColor = (text) =>
text.replace(/\x1B\[\d+m/g, "");
Testing ASCII escape codes
As you’ve seen already, when Jest prints out test failures, you’ll see a bunch of red and green colorful text. That’s achieved by printing ASCII escape codes within the text string.
This is a tricky thing to test. Because of that, we’re making a pragmatic choice to not bother testing colors. Instead, the stripTerminalColor function strips out these escape codes from the string so that you can test the text output as if it was plain text.
import {
matcherHint,
printExpected,
} from "jest-matcher-utils";
export const toContainText = (
received,
expectedText
) => {
const pass =
received.textContent.includes(expectedText);
const message = () =>
matcherHint(
"toContainText",
"element",
printExpected(expectedText),
{ }
);
return { pass, message };
};
Learning about Jest’s matcher utilities
At the time of writing, I’ve found the best way to learn what the Jest matcher utility functions do is to read their source. You could also avoid them entirely if you like – there’s no obligation to use them.
it("returns a message that contains the source line if negated match", () => {
const domElement = { textContent: "text to find" };
const result = toContainText(
domElement,
"text to find"
);
expect(
stripTerminalColor(result.message())
).toContain(
`expect(container).not.toContainText("text to find")`
);
});
...
matcherHint(
"toContainText",
"element",
printExpected(expectedText),
{ isNot: pass }
);
...
it("returns a message that contains the actual text", () => {
const domElement = { textContent: "text to find" };
const result = toContainText(
domElement,
"text to find"
);
expect(
stripTerminalColor(result.message())
).toContain(`Actual text: "text to find"`);
});
import {
matcherHint,
printExpected,
printReceived,
} from "jest-matcher-utils";
export const toContainText = (
received,
expectedText
) => {
const pass =
received.textContent.includes(expectedText);
const sourceHint = () =>
matcherHint(
"toContainText",
"element",
printExpected(expectedText),
{ isNot: pass }
);
const actualTextHint = () =>
"Actual text: " +
printReceived(received.textContent);
const message = () =>
[sourceHint(), actualTextHint()].join("\n\n");
return { pass, message };
};
import {
toContainText
} from "./matchers/toContainText";
expect.extend({
toContainText,
});
"jest": {
...,
"setupFilesAfterEnv": ["./test/domMatchers.js"]
}
Why do we test-drive matchers?
You should write tests for any code that isn’t just simply calling other functions or setting variables. At the start of this chapter, you extracted functions such as render and click. These functions didn’t need tests because you were just transplanting the same line of code from one file to another. But this matcher does something much more complex – it must return an object that conforms to the pattern that Jest requires. It also makes use of Jest’s utility functions to build up a helpful message. That complexity warrants tests.
If you are building matchers for a library, you should be more careful with your matcher’s implementation. For example, we didn’t bother to check that the received value is an HTML element. That’s fine because this matcher exists in our code base only, and we control how it’s used. When you package matchers for use in other projects, you should also verify that the function inputs are values you’re expecting to see.
You’ve now successfully test-driven your first matcher. There will be more opportunities for you to practice this skill as this book progresses. For now, we’ll move on to the final part of our cleanup: creating some fluent DOM helpers.
In this section, we’ll pull out a bunch of little functions that will help our tests become more readable. This will be straightforward compared to the matcher we’ve just built.
The reactTestExtensions.js module already contains three functions that you’ve used: initializeReactContainer, render, and click.
Now, we’ll add four more: element, elements, typesOf, and textOf. These functions are designed to help your tests read much more like plain English. Let’s take a look at an example. Here are the expectations for one of our tests:
const listChildren = document.querySelectorAll("li");
expect(listChildren[0].textContent).toEqual("12:00");
expect(listChildren[1].textContent).toEqual("13:00");
We can introduce a function, elements, that is a shorter version of document.querySelectorAll. The shorter name means we can get rid of the extra variable:
expect(elements("li")[0].textContent).toEqual("12:00");
expect(elements("li")[1].textContent).toEqual("13:00");
This code is now calling querySelectorAll twice – so it’s doing more work than before – but it’s also shorter and more readable. And we can go even further. We can boil this down to one expect call by matching on the elements array itself. Since we need textContent, we will simply build a mapping function called textOf that takes that input array and returns the textContent property of each element within it:
expect(textOf(elements("li"))).toEqual(["12:00", "13:00"]);
The toEqual matcher, when applied to arrays, will check that each array has the same number of elements and that each element appears in the same place.
We’ve reduced our original three lines of code to just one!
Let’s go ahead and build these new helpers:
export const element = (selector) =>
document.querySelector(selector);
export const elements = (selector) =>
Array.from(document.querySelectorAll(selector));
export const typesOf = (elements) =>
elements.map((element) => element.type);
export const textOf = (elements) =>
elements.map((element) => element.textContent);
import {
initializeReactContainer,
render,
click,
element,
elements,
textOf,
typesOf,
} from "./reactTestExtensions";
expect(textOf(elements("li"))).toEqual([
"12:00", "13:00"
]);
expect(typesOf(elements("li > *"))).toEqual([
"button",
"button",
]);
const secondButton = () => elements("button")[1];
click(secondButton());
expect(secondButton().className).toContain("toggled");
expect(element("ol")).not.toBeNull();
Not all helpers need to be extracted
You’ll notice that the helpers you have extracted are all very generic – they make no mention of the specific components under test. It’s good to keep helpers as generic as possible. On the other hand, sometimes it helps to have very localized helper functions. In your test suite, you already have one called appointmentsTable and another called secondButton. These should remain in the test suite because they are local to the test suite.
In this section, you’ve seen our final technique for simplifying your test suites, which is to pull out fluent helper functions that help keep your expectations short and help them read like plain English.
You've also seen the trick of running expectations on an array of items rather than having an expectation for individual items. This isn’t always the appropriate course of action. You’ll see an example of this in Chapter 5, Adding Complex Form Interactions.
This chapter focused on improving our test suites. Readability is crucially important. Your tests act as specifications for your software. Each component test must clearly state what the expectation of the component is. And when a test fails, you want to be able to understand why it’s failed as quickly as possible.
You’ve seen that these priorities are often in tension with our usual idea of what good code is. For example, in our tests, we are willing to sacrifice performance if it makes the tests more readable.
If you’ve worked with React tests in the past, think about how long an average test was.In this chapter, you've seen a couple of mechanisms for keeping your test short: building domain-specific matchers and extracting little functions for querying the DOM.
You’ve also learned how to pull out React initialization code to avoid clutter in our test suites.
In the next chapter, we’ll move back to building new functionality into our app: data entry with forms.
Using the techniques you’ve just learned, create a new matcher named toHaveClass that replaces the following expectation:
expect(secondButton().className).toContain("toggled");
With your new matcher in place, it should read as follows:
expect(secondButton()).toHaveClass("toggled");
There is also the negated form of this matcher:
expect(secondButton().className).not.toContain("toggled");
Your matcher should work for this form and display an appropriate failure message.
To learn more about the topics that were covered in this chapter, take a look at the following resources:
In this chapter, you’ll explore React forms and controlled components.
Forms are an essential part of building web applications, being the primary way that users enter data. If we want to ensure our application works, then invariably, that’ll mean we need to write automated tests for our forms. What’s more, there’s a lot of plumbing required to get forms working in React, making it even more important that they’re well-tested.
Automated tests for forms are all about the user’s behavior: entering text, clicking buttons, and submitting the form when complete.
We will build out a new component, CustomerForm, which we will use when adding or modifying customers. It will have three text fields: first name, last name, and phone number.
In the process of building this form, you’ll dig deeper into testing complex DOM element trees. You’ll learn how to use parameterized tests to repeat a group of tests without duplicating code.
The following topics will be covered in this chapter:
By the end of this chapter, you’ll have a decent understanding of test-driving HTML forms with React.
The code files for this chapter can be found here: https://github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter04.
An HTML form is a bunch of fields wrapped in a form element. Even though we’re mostly interested in the fields, we need to start with the form element itself. That’s what we’ll build in this section.
Let’s create our first form by following these steps:
import React from "react";
import {
initializeReactContainer,
render,
element,
} from "./reactTestExtensions";
import { CustomerForm } from "../src/CustomerForm";
describe("CustomerForm", () => {
beforeEach(() => {
initializeReactContainer();
});
});
it("renders a form", () => {
render(<CustomerForm />);
expect(element("form")).not.toBeNull();
});
FAIL test/CustomerForm.test.js
● Test suite failed to run
Cannot find module '../src/CustomerForm' from 'CustomerForm.test.js'
The failure tells us that it can’t find the module. That’s because we haven’t created it yet.
FAIL test/CustomerForm.test.js
● CustomerForm › renders a form
Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.
8 |
9 | export const render = (component) =>
> 10 | act(() =>
11 | ReactDOM.createRoot(...).render(...)
| ^
12 | );
11 |
12 | export const click = (element) =>
13 | act(() => element.click());
Stack traces from test helper code
Jest’s stack trace points to a failure within our extensions code, not the test itself. If our code was in an npm module, Jest would have skipped those test lines from its output. Thankfully, the error message is helpful enough.
export const CustomerForm = () => null;
● CustomerForm › renders a form
expect(received).not.toBeNull()
Received: null
This can be fixed by making the component return something:
import React from "react";
export const CustomerForm = () => <form />;
Before moving on, let’s pull out a helper for finding the form element. As in the previous chapter, this is arguably premature as we have only one test using this code right now. However, we’ll appreciate having the helper when we come to write our form submission tests later.
export const form = (id) => element("form");
import {
initializeReactContainer,
render,
element,
form,
} from "./reactTestExtensions";
it("renders a form", () => {
render(<CustomerForm />);
expect(form()).not.toBeNull();
});
That’s all there is to creating the basic form element. With that wrapper in place, we’re now ready to add our first field element: a text box.
In this section, we’ll add a text box to allow the customer’s first name to be added or edited.
Adding a text field is more complicated than adding the form element. First, there’s the element itself, which has a type attribute that needs to be tested. Then, we need to prime the element with the initial value. Finally, we’ll need to add a label so that it’s obvious what the field represents.
Let’s start by rendering an HTML text input field onto the page:
it("renders the first name field as a text box", () => {
render(<CustomerForm />);
const field = form().elements.firstName;
expect(field).not.toBeNull();
expect(field.tagName).toEqual("INPUT");
expect(field.type).toEqual("text");
});
Relying on the DOM’s Form API
This test makes use of the Form API: any form element allows you to access all of its input elements using the elements indexer. You give it the element’s name attribute (in this case, firstName) and that element is returned.
This means we must check the returned element’s tag. We want to make sure it is an <input> element. If we hadn’t used the Form API, one alternative would have been to use elements("input")[0], which returns the first input element on the page. This would make the expectation on the element’s tagName property unnecessary.
export const CustomerForm = () => (
<form
<input type="text" name="firstName" />
</form>
);
it("includes the existing value for the first name", () => {
const customer = { firstName: "Ashley" };
render(<CustomerForm original={customer} />);
const field = form().elements.firstName;
expect(field.value).toEqual("Ashley");
});
export const CustomerForm = ({ original }) => (
<form
<input
type="text"
name="firstName"
value={original.firstName} />
</form>
);
Warning: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
const blankCustomer = {
firstName: "",
};
What about specifying an empty object for the original prop?
In this object definition, we set the firstName value to an empty string. You may think that either undefined or null would be good candidates for the value. That way, we could sidestep having to define an object like this and just pass an empty object, {}. Unfortunately, React will warn you when you attempt to set a controlled component’s initial value to undefined, which we want to avoid. It’s no big deal, and besides that, an empty string is a more realistic default for a text box.
it("renders a form", () => {
render(<CustomerForm original={blankCustomer} />);
expect(form()).not.toBeNull();
});
it("renders the first name field as a text box", () => {
render(<CustomerForm original={blankCustomer} />);
const field = form().elements.firstName;
expect(field).not.toBeNull();
expect(field.tagName).toEqual("INPUT");
expect(field.type).toEqual("text");
});
<input
type="text"
name="firstName"
value={original.firstName}
readOnly
/>
Tip
Always consider React warnings to be a test failure. Don’t proceed without first fixing any warnings.
const field = form().elements.firstName;
Let’s promote this to be a function in test/reactTestExtensions.js. Open that file and add the following definition after the definition for form:
export const field = (fieldName) =>
form().elements[fieldName];
import {
initializeReactContainer,
render,
element,
form,
field,
} from "./reactTestExtensions";
it("includes the existing value for the first name", () => {
const customer = { firstName: "Ashley" };
render(<CustomerForm original={customer} />);
expect(field("firstName").value).toEqual("Ashley");
});
it("renders the first name field as a text box", () => {
render(<CustomerForm original={blankCustomer} />);
expect(field("firstName")).not.toBeNull();
expect(field("firstName")).toEqual("INPUT");
expect(field("firstName")).toEqual("text");
});
it("renders a label for the first name field", () => {
render(<CustomerForm original={blankCustomer} />);
const label = element("label[for=firstName]");
expect(label).not.toBeNull();
});
<form
<label htmlFor="firstName" />
...
</form>
The htmlFor attribute
The JSX htmlFor attribute sets the HTML for attribute. for couldn’t be used in JSX because it is a reserved JavaScript keyword. The attribute is used to signify that the label matches a form element with the given ID – in this case, firstName.
it("renders 'First name' as the first name label content", () => {
render(<CustomerForm original={blankCustomer} />);
const label = element("label[for=firstName]");
expect(label).toContainText("First name");
});
<form
<label htmlFor="firstName">First name</label>
...
</form>
it("assigns an id that matches the label id to the first name field", () => {
render(<CustomerForm original={blankCustomer} />);
expect(field("firstName").id).toEqual("firstName");
});
<form>
<label htmlFor="firstName">First name</label>
<input
type="text"
name="firstName"
id="firstName"
value={firstName}
readOnly
/>
</form>
We’ve now created almost everything we need for this field: the input field itself, its initial value, and its label. But we don’t have any behavior for handling changes to the value – that’s why we have the readOnly flag.
Change behavior only makes sense in the context of submitting the form with updated data: if you can’t submit the form, there’s no point in changing the field value. That’s what we’ll cover in the next section.
For this chapter, we will define “submit the form” to mean “call the onSubmit callback function with the current customer object.” The onSubmit callback function is a prop we’ll be passing.
This section will introduce one way of testing form submission. In Chapter 6, Exploring Test Doubles, we will update this to a call to global.fetch that sends our customer data to our application’s backend API.
We’ll need a few different tests to specify this behavior, each test building up the functionality we need in a step-by-step fashion. First, we’ll have a test that ensures the form has a submit button. Then, we’ll write a test that clicks that button without making any changes to the form. We’ll need another test to check that submitting the form does not cause page navigation to occur. Finally, we’ll end with a test submission after the value of the text box has been updated.
Let’s start by creating a button in the form. Clicking it will cause the form to submit:
it("renders a submit button", () => {
render(<CustomerForm original={blankCustomer} />);
const button = element("input[type=submit]");
expect(button).not.toBeNull();
});
<form>
...
<input type="submit" value="Add" />
</form>
it("saves existing first name when submitted", () => {
expect.hasAssertions();
});
The hasAssertions expectation tells Jest that it should expect at least one assertion to occur. It tells Jest that at least one assertion must run within the scope of the test; otherwise, the test has failed. You’ll see why this is important in the next step.
const customer = { firstName: "Ashley" };
render(
<CustomerForm
original={customer}
onSubmit={({ firstName }) =>
expect(firstName).toEqual("Ashley")
}
/>
);
This function call is a mix of the Arrange and Assert phases in one. The Arrange phase is the render call itself, and the Assert phase is the onSubmit handler. This is the handler that we want React to call on form submission.
const button = element("input[type=submit]");
click(button);
Using hasAssertions to avoid false positives
You can now see why we need hasAssertions. The test is written out of order, with the assertions defined within the onSubmit handler. If we did not use hasAssertions, this test would pass right now because we never call onSubmit.
I don’t recommend writing tests like this. In Chapter 6, Exploring Test Doubles, we’ll discover test doubles, which allow us to restore the usual Arrange-Act-Assert order to help us avoid the need for hasAssertions. The method we’re using here is a perfectly valid TDD practice; it’s just a little messy, so you will want to refactor it eventually.
import {
initializeReactContainer,
render,
element,
form,
field,
click,
} from "./reactTestExtensions";
export const CustomerForm = ({
original,
onSubmit
}) => (
<form onSubmit={() => onSubmit(original)}>
...
</form>
);
console.error
Error: Not implemented: HTMLFormElement.prototype.submit
at module.exports (.../node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17)
Something is not quite right. This warning is highlighting something very important that we need to take care of. Let’s stop here and look at it in detail.
This Not implemented console error is coming from the JSDOM package. HTML forms have a default action when submitted: they navigate to another page, which is specified by the form element’s action attribute. JSDOM does not implement page navigation, which is why we get a Not implemented error.
In a typical React application like the one we’re building, we don’t want the browser to navigate. We want to stay on the same page and allow React to update the page with the result of the submit operation.
The way to do that is to grab the event argument from the onSubmit prop and call preventDefault on it:
event.preventDefault();
Since that’s production code, we need a test that verifies this behavior. We can do this by checking the event’s defaultPrevented property:
expect(event.defaultPrevented).toBe(true);
So, now the question becomes, how do we get access to this Event in our tests?
We need to create the event object ourselves and dispatch it directly using the dispatchEvent DOM function on the form element. This event needs to be marked as cancelable, which will allow us to call preventDefault on it.
Why clicking the submit button won’t work
In the last couple of tests, we purposely built a submit button that we could click to submit the form. While that will work for all our other tests, for this specific test, it does not work. That’s because JSDOM will take a click event and internally convert it into a submit event. There is no way we can get access to that submit event object if JSDOM creates it. Therefore, we need to directly fire the submit event.
This isn’t a problem. Remember that, in our test suite, we strive to act as a real browser would – by clicking a submit button to submit the form – but having one test work differently isn’t the end of the world.
Let’s put all of this together and fix the warning:
export const submit = (formElement) => {
const event = new Event("submit", {
bubbles: true,
cancelable: true,
});
act(() => formElement.dispatchEvent(event));
return event;
};
Why do we need the bubbles property?
If all of this wasn’t complicated enough, we also need to make sure the event bubbles; otherwise, it won’t make it to our event handler.
When JSDOM (or the browser) dispatches an event, it traverses the element hierarchy looking for an event handler to handle the event, starting from the element the event was dispatched on, working upwards via parent links to the root node. This is known as bubbling.
Why do we need to ensure this event bubbles? Because React has its own event handling system that is triggered by events reaching the React root element. The submit event must bubble up to our container element before React will process it.
import {
...,
submit,
} from "./reactTestExtensions";
it("prevents the default action when submitting the form", () => {
render(
<CustomerForm
original={blankCustomer}
onSubmit={() => {}}
/>
);
const event = submit(form());
expect(event.defaultPrevented).toBe(true);
});
export const CustomerForm = ({
original,
onSubmit
}) => {
return (
<form onSubmit={() => onSubmit(original)}>
...
</form>
);
};
export const CustomerForm = ({
original,
onSubmit
}) => {
const handleSubmit = (event) => {
event.preventDefault();
onSubmit(original);
};
return (
<form onSubmit={handleSubmit}>
</form>
);
};
It’s finally the time to introduce some state into our component. We will specify what should happen when the text field is used to update the customer’s first name.
The most complicated part of what we’re about to do is dispatching the DOM change event. In the browser, this event is dispatched after every keystroke, notifying the JavaScript application that the text field value content has changed. An event handler receiving this event can query the target element’s value property to find out what the current value is.
Crucially, we’re responsible for setting the value property before we dispatch the change event. We do that by calling the value property setter.
Somewhat unfortunately for us testers, React has change tracking behavior that is designed for the browser environment, not the Node test environment. In our tests, this change tracking logic suppresses change events like the ones our tests will dispatch. We need to circumvent this logic, which we can do with a helper function called originalValueProperty, as shown here:
const originalValueProperty = (reactElement) => {
const prototype =
Object.getPrototypeOf(reactElement);
return Object.getOwnPropertyDescriptor(
prototype,
"value"
);
};
As you’ll see in the next section, we’ll use this function to bypass React’s change tracking and trick it into processing our event, just like a browser would.
Only simulating the final change
Rather than creating a change event for each keystroke, we’ll manufacture just the final instance. Since the event handler always has access to the full value of the element, it can ignore all intermediate events and process just the last one that is received.
Let’s begin with a little bit of refactoring:
const button = element("input[type=submit]");
Let’s move this definition into test/reactTestExtensions.js so that we can use it on our future tests. Open that file now and add this definition to the bottom:
export const submitButton = () =>
element("input[type=submit]");
import {
...,
submitButton,
} from "./reactTestExtensions";
it("renders a submit button", () => {
render(<CustomerForm original={blankCustomer} />);
expect(submitButton()).not.toBeNull();
});
The helper extraction dance
Why are we doing this dance of writing a variable in a test (such as const button = ...) only to then extract it as a function moments later, as we just did with submitButton?
Following this approach is a systematic way of building a library of helper functions, meaning you don’t have to think too heavily about the “right” design. First, start with a variable. If it turns out that you’ll use that variable a second or third time, then extract it into a function. No big deal.
it("saves new first name when submitted", () => {
expect.hasAssertions();
render(
<CustomerForm
original={blankCustomer}
onSubmit={({ firstName }) =>
expect(firstName).toEqual("Jamie")
}
/>
);
change(field("firstName"), "Jamie");
click(submitButton());
});
const originalValueProperty = (reactElement) => {
const prototype =
Object.getPrototypeOf(reactElement);
return Object.getOwnPropertyDescriptor(
prototype,
"value"
);
};
export const change = (target, value) => {
originalValueProperty(target).set.call(
target,
value
);
const event = new Event("change", {
target,
bubbles: true,
});
act(() => target.dispatchEvent(event));
};
Figuring out interactions between React and JSDOM
The implementation of the change function shown here is not obvious. As we saw earlier with the bubbles property, React does some pretty clever stuff on top of the DOM’s usual event system.
It helps to have a high-level awareness of how React works. I also find it helpful to use the Node debugger to step through JSDOM and React source code to figure out where the flow is breaking.
import React, { useState } from "react";
const [ customer, setCustomer ] = useState(original);
const handleChangeFirstName = ({ target }) =>
setCustomer((customer) => ({
...customer,
firstName: target.value
}));
<input
type="text"
name="firstName"
id="firstName"
value={customer.firstName}
onChange={handleChangeFirstName}
/>
With that, you’ve learned how to test-drive the change DOM event, and how to hook it up with React’s component state to save the user’s input. Next, it’s time to repeat the process for two more text boxes.
So far, we’ve written a set of tests that fully define the firstName text field. Now, we want to add two more fields, which are essentially the same as the firstName field but with different id values and labels.
Before you reach for copy and paste, stop and think about the duplication you could be about to add to both your tests and your production code. We have six tests that define the first name. This means we would end up with 18 tests to define three fields. That’s a lot of tests without any kind of grouping or abstraction.
So, let’s do both – that is, group our tests and abstract out a function that generates our tests for us.
We can nest describe blocks to break similar tests up into logical contexts. We can invent a convention for how to name these describe blocks. Whereas the top level is named after the form itself, the second-level describe blocks are named after the form fields.
Here’s how we’d like them to end up:
describe("CustomerForm", () => {
describe("first name field", () => {
// ... tests ...
};
describe("last name field", () => {
// ... tests ...
};
describe("phone number field", () => {
// ... tests ...
};
});
With this structure in place, you can simplify the it descriptive text by removing the name of the field. For example, "renders the first name field as a text box" becomes "renders as a text box" because it has already been scoped by the "first name field" describe block. Because of the way Jest displays describe block names before test names in the test output, each of these still reads like a plain-English sentence, but without the verbiage. In the example just given, Jest will show us CustomerForm first name field renders as a text box.
Let’s do that now for the first name field. Wrap the six existing tests in a describe block, and then rename the tests, as shown here:
describe("first name field", () => {
it("renders as a text box" ... );
it("includes the existing value" ... );
it("renders a label" ... );
it("assigns an id that matches the label id" ... );
it("saves existing value when submitted" ... );
it("saves new value when submitted" ... );
});
Be careful not to include the preventsDefault test out of this, as it’s not field-specific. You may need to adjust the positioning of your tests in your test file.
That covers grouping the tests. Now, let’s look at using test generator functions to remove repetition.
Some programming languages, such as Java and C#, require special framework support to build parameterized tests. But in JavaScript, we can very easily roll our own parameterization because our test definitions are just function calls. We can use this to our advantage by pulling out each of the existing six tests as functions that take parameter values.
This kind of change requires some diligent refactoring. We’ll do the first two tests together, and then you can either repeat these steps for the remaining five tests or jump ahead to the next tag in the GitHub repository:
const itRendersAsATextBox = () =>
it("renders as a text box", () => {
render(<CustomerForm original={blankCustomer} />);
expect(field("firstName")).not.toBeNull();
expect(field("firstName").tagName).toEqual(
"INPUT"
);
expect(field("firstName").type).toEqual("text");
});
itRendersAsATextBox();
const itRendersAsATextBox = (fieldName) =>
it("renders as a text box", () => {
render(<CustomerForm original={blankCustomer} />);
expect(field(fieldName)).not.toBeNull();
expect(field(fieldName).tagName).toEqual("INPUT");
expect(field(fieldName).type).toEqual("text");
});
itRendersAsATextBox("firstName");
const itIncludesTheExistingValue = (
fieldName,
existing
) =>
it("includes the existing value", () => {
const customer = { [fieldName]: existing };
render(<CustomerForm original={customer} />);
expect(field(fieldName).value).toEqual(existing);
});
itIncludesTheExistingValue("firstName", "Ashley");
const itRendersALabel = (fieldName, text) => {
it("renders a label for the text box", () => {
render(<CustomerForm original={blankCustomer} />);
const label = element(`label[for=${fieldName}]`);
expect(label).not.toBeNull();
});
it(`renders '${text}' as the label content`, () => {
render(<CustomerForm original={blankCustomer} />);
const label = element(`label[for=${fieldName}]`);
expect(label).toContainText(text);
});
};
const itAssignsAnIdThatMatchesTheLabelId = (
fieldName
) =>
...
const itSubmitsExistingValue = (fieldName, value) =>
...
const itSubmitsNewValue = (fieldName, value) =>
...
Important note
Check the completed solution for the full listing. This can be found in the Chapter04/Complete directory.
describe("first name field", () => {
itRendersAsATextBox("firstName");
itIncludesTheExistingValue("firstName", "Ashley");
itRendersALabel("firstName", "First name");
itAssignsAnIdThatMatchesTheLabelId("firstName");
itSubmitsExistingValue("firstName", "Ashley");
itSubmitsNewValue("firstName", "Jamie");
});
Take a step back and look at the new form of the describe block. It is now very quick to understand the specification for how this field should work.
Now, we want to duplicate those six tests for the last name field. But how do we approach this? We do this test by test, just as we did with the first name field. However, this time, we should go much faster as our tests are one-liners, and the production code is a copy and paste job.
So, for example, the first test will be this:
describe("last name field", () => {
itRendersAsATextBox("lastName");
});
You’ll need to update blankCustomer so that it includes the new field:
const blankCustomer = {
firstName: "",
lastName: "",
};
That test can be made to pass by adding the following line to our JSX, just below the firstName input field:
<input type="text" name="lastName" />
This is just the start for the input field; you’ll need to complete it as you add the next few tests.
Go ahead and add the remaining five tests, along with their implementation. Then, repeat this process for the phone number field. When adding the submit tests for the phone number, make sure that you provide a string value made up of numbers, such as "012345". Later in this book, we’ll add validations to this field that will fail if you don’t use the right values now.
Jumping ahead
You might be tempted to try to solve all 12 new tests at once. If you’re feeling confident, go for it!
If you want to see a listing of all the tests in a file, you must invoke Jest with a single file. Run the npm test test/CustomerForm.test.js command to see what that looks like. Alternatively, you can run npx jest --verbose to run all the tests with full test listings:
PASS test/CustomerForm.test.js CustomerForm ✓ renders a form (28ms) first name field ✓ renders as a text box (4ms) ✓ includes the existing value (3ms) ✓ renders a label (2ms) ✓ saves existing value when submitted (4ms) ✓ saves new value when submitted (5ms) last name field ✓ renders as a text box (3ms) ✓ includes the existing value (2ms) ✓ renders a label (6ms) ✓ saves existing value when submitted (2ms) ✓ saves new value when submitted (3ms) phone number field ✓ renders as a text box (2ms) ✓ includes the existing value (2ms) ✓ renders a label (2ms) ✓ saves existing value when submitted (3ms) ✓ saves new value when submitted (2ms)
Time for a small refactor. After adding all three fields, you will have ended up with three very similar onChange event handlers:
const handleChangeFirstName = ({ target }) =>
setCustomer((customer) => ({
...customer,
firstName: target.value
}));
const handleChangeLastName = ({ target }) =>
setCustomer((customer) => ({
...customer,
lastName: target.value
}));
const handleChangePhoneNumber = ({ target }) =>
setCustomer((customer) => ({
...customer,
phoneNumber: target.value
}));
You can simplify these down into one function by making use of the name property on target, which matches the field ID:
const handleChange = ({ target }) =>
setCustomer(customer => ({
...customer,
[target.name]: target.value
}));
At this stage, your the AppointmentsDayView instance is complete. Now is a good time to try it out for real.
Update your entry point in src/index.js so that it renders a new CustomerForm instance, rather than AppointmentsDayView. By doing so, you should be ready to manually test:
Figure 4.1 – The completed CustomerForm
With that, you have learned one way to quickly duplicate specifications across multiple form fields: since describe and it are plain old functions, you can treat them just like you would with any other function and build your own structure around them.
In this chapter, you learned how to create an HTML form with text boxes. You wrote tests for the form element, and for input elements of types text and submit.
Although the text box is about the most basic input element there is, we’ve taken this opportunity to dig much deeper into test-driven React. We’ve discovered the intricacies of raising submit and change events via JSDOM, such as ensuring that event.preventDefault() is called on the event to avoid a browser page transition.
We’ve also gone much further with Jest. We extracted common test logic into modules, used nested describe blocks, and built assertions using DOM’s Form API.
In the next chapter, we’ll test-drive a more complicated form example: a form with select boxes and radio buttons.
The following are some exercises for you to try out:
expect(labelFor(fieldName)).not.toBeNull();
expect(field(fieldName)).toBeInputFieldOfType("text");
It’s time to apply what you’ve learned to a more complicated HTML setup. In this chapter, we’ll test-drive a new component: AppointmentForm. It contains a select box, for selecting the service required, and a grid of radio buttons that form a calendar view for selecting the appointment time.
Combining both layout and form input, the code in this chapter shows how TDD gives you a structure for your work that makes even complicated scenarios straightforward: you will use your tests to grow the component into a component hierarchy, splitting out functionality from the main component as it begins to grow.
In this chapter, we will cover the following topics:
By the end of the chapter, you’ll have learned how to apply test-driven development to complex user input scenarios. These techniques will be useful for all kinds of form components, not just select boxes and radio buttons.
The code files for this chapter can be found here: https://github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter05.
Let’s start by creating a component for booking new appointments, named AppointmentForm.
The first field is a select box for choosing which service the customer requires: cut, color, blow-dry, and so on. Let’s create that now:
import React from "react";
import {
initializeReactContainer,
render,
field,
form,
} from "./reactTestExtensions";
import { AppointmentForm } from "../src/AppointmentForm";
describe("AppointmentForm", () => {
beforeEach(() => {
initializeReactContainer();
});
it("renders a form", () => {
render(<AppointmentForm />);
expect(form()).not.toBeNull();
});
});
import React from "react";
export const AppointmentForm = () => <form />;
describe("service field", () => {
});
it("renders as a select box", () => {
render(<AppointmentForm />);
expect(field("service").not.toBeNull();
expect(field("service").tagName).toEqual("SELECT");
});
export const AppointmentForm = () => (
<form
<select name="service" />
</form>
);
With that, we’ve done the basic scaffolding for the new select box field so that it’s ready to be populated with option elements.
Our salon provides a whole range of salon services. We should ensure that they are all listed in the app. We could start our test by defining our expectations, like this:
it("lists all salon services", () => {
const selectableServices = [
"Cut",
"Blow-dry",
"Cut & color",
"Beard trim",
"Cut & beard trim",
"Extensions"
];
...
});
If we do this, we’ll end up repeating the same array of services in our test code and our production code. We can avoid that repetition by focusing our unit tests on the behavior of the select box rather than the static data that populates it: what should the select box do?
As it turns out, we can specify the functionality of our select box with just two items in our array. There’s another good reason for keeping it to just two, which is that keeping the array brief helps us focus the test on what’s important: the behavior, not the data.
That leaves the question, how do we use only two items in our test when we need six items for the production code?
We’ll do this by introducing a new prop, selectableServices, to AppointmentForm. Our tests can choose to specify a value if they need to. In our production code, we can specify a value for the component’s defaultProps.
defaultProps is a nifty mechanism that React offers for setting default prop values that will be used when required props are not explicitly provided.
For our tests that don’t care about the select box values, we can avoid passing the prop and ignore it entirely in the test. For the tests that do care, we can provide a short, two-item array for our tests.
How do we verify the real select box values?
Testing static data does happen, just not within our unit tests. One place this can be tested is within acceptance tests, which we’ll look at in Part 4, Behavior-Driven Development with Cucumber.
We’ll start with a test to ensure the first value is a blank entry. This is the value that’s initially selected when the user creates a new appointment: no option is selected. Let’s write that test now:
it("has a blank value as the first value", () => {
render(<AppointmentForm />);
const firstOption = field("service").childNodes[0];
expect(firstOption.value).toEqual("");
});
export const AppointmentForm = () => (
<form
<select name="service">
<option />
</select>
</form>
);
const labelsOfAllOptions = (element) =>
Array.from(
element.childNodes,
(node) => node.textContent
);
it("lists all salon services", () => {
const services = ["Cut", "Blow-dry"];
render(
<AppointmentForm selectableServices={services} />
);
expect(
labelsOfAllOptions(field("service"))
).toEqual(expect.arrayContaining(services));
});
Choosing test data
I’ve used “real” data for my expected services: Cut and Blow-dry. It’s also fine to use non-real names such as Service A and Service B. Often, that can be more descriptive. Both are valid approaches.
export const AppointmentForm = ({
selectableServices
}) => (
<form>
<select name="service">
<option />
{selectableServices.map(s => (
<option key={s}>{s}</option>
))}
</select>
</form>
);
AppointmentForm.defaultProps = {
selectableServices: [
"Cut",
"Blow-dry",
"Cut & color",
"Beard trim",
"Cut & beard trim",
"Extensions",
]
};
That’s all there is to it. With that, we’ve learned how to define the behavior of our component using a short two-item array and saved the real data for defaultProps.
Let’s ensure that our component preselects the value that has already been saved if we’re editing an existing appointment:
const findOption = (selectBox, textContent) => {
const options = Array.from(selectBox.childNodes);
return options.find(
option => option.textContent === textContent
);
};
it("pre-selects the existing value", () => {
const services = ["Cut", "Blow-dry"];
const appointment = { service: "Blow-dry" };
render(
<AppointmentForm
selectableServices={services}
original={appointment}
/>
);
const option = findOption(
field("service"),
"Blow-dry"
);
expect(option.selected).toBe(true);
});
<select
name="service"
value={original.service}
readOnly>
Accessible rich internet applications (ARIA) labels
If you have experience with building React applications, you may be expecting to set the aria-label property on the select element. However, one of this chapter’s Exercises is to add a label element for this select box that will ensure an ARIA label is set implicitly by the browser.
export const AppointmentForm = ({
original,
selectableServices
}) =>
const blankAppointment = {
service: "",
};
it("renders a form", () => {
render(
<AppointmentForm original={blankAppointment} />
);
expect(form()).not.toBeNull();
});
describe("AppointmentForm", () => {
const blankAppointment = {
service: "",
};
const services = ["Cut", "Blow-dry"];
...
});
That completes this test, but there is still more functionality to add if we want a fully functional select box. Completing those tests is left as one of the Exercises at the end of this chapter. They work the same as the tests for the text boxes in CustomerForm.
If you compare our select box tests to those of the text box, you will see that it’s a similar pattern but with a couple of additional techniques: we used defaultProps to separate the definition of production data from test behavior, and we defined a couple of localized helper methods, labelsOfAllOptions and findOption, to help keep our tests short.
Let’s move on to the next item in our form: the time of the appointment.
In this section, we’ll learn how to use our existing helpers, such as element and elements, mixed with CSS selectors, to select specific elements we’re interested in within our HTML layout.
But first, let’s start with some planning.
We’d like AppointmentForm to display available time slots over the next 7 days as a grid, with columns representing days and rows representing 30-minute time slots, just like a standard calendar view. The user will be able to quickly find a time slot that works for them and then select the right radio button before submitting the form:
Figure 5.1 – The visual design of our calendar view
Here’s an example of the HTML structure that we’re aiming to build. We can use this as a guide as we write out our React component:
<table id="time-slots"> <thead> <tr> <th></th> <th>Oct 11</th> <th>Oct 12</th> <th>Oct 13</th> </tr> </thead> <tbody> <tr> <th>9:00</th> <td> <input type="option" name="timeSlot" value="..." /> </td> </tr> <!-- ... two more cells ... --> </tbody> </table>
In the next few sections, we’ll test-drive the table element itself, then build a header column for times of the day, and then a header for days of the week.
Let’s begin by building table itself:
describe("time slot table", () => {
it("renders a table for time slots with an id", () => {
render(
<AppointmentForm original={blankAppointment} />
);
expect(
element("table#time-slots")
).not.toBeNull();
});
});
import {
initializeReactContainer,
render,
field,
form,
element,
} from "./reactTestExtensions";
const TimeSlotTable = () => <table id="time-slots" />;
Why add an ID?
The ID is important because that’s what the application’s CSS uses to find the table element. Although it’s not covered in this book, if you’re using CSS and it defines selectors based on element IDs, then you should treat those IDs as a kind of technical specification that your code must satisfy. That’s why we write unit tests for them.
<form>
...
<TimeSlotTable />
</form>;
Run the tests and verify that they are all passing.
That’s all there is to the table element. Now, let’s get some data into the first column.
For the next test, we’ll test the left-hand header column that displays a list of times. We’ll introduce two new props, salonOpensAt and salonClosesAt, which inform the component of which time to show each day. Follow these steps:
it("renders a time slot for every half an hour between open and close times", () => {
render(
<AppointmentForm
original={blankAppointment}
salonOpensAt={9}
salonClosesAt={11}
/>
);
const timesOfDayHeadings = elements("tbody >* th");
expect(timesOfDayHeadings[0]).toContainText(
"09:00"
);
expect(timesOfDayHeadings[1]).toContainText(
"09:30"
);
expect(timesOfDayHeadings[3]).toContainText(
"10:30"
);
});
Asserting on array patterns
In this example, we are checking textContent on three array entries, even though there are four entries in the array.
Properties that are the same for all array entries only need to be tested on one entry. Properties that vary per entry, such as textContent, need to be tested on two or three entries, depending on how many you need to test a pattern.
For this test, I want to test that it starts and ends at the right time and that each time slot increases by 30 minutes. I can do that with assertions on array entries 0, 1, and 3.
This test “breaks” our rule of one expectation per test. However, in this scenario, I think it’s okay. An alternative approach might be to use the textOf helper instead.
import {
initializeReactContainer,
render,
field,
form,
element,
elements,
} from "./reactTestExtensions";
const timeIncrements = (
numTimes,
startTime,
increment
) =>
Array(numTimes)
.fill([startTime])
.reduce((acc, _, i) =>
acc.concat([startTime + i * increment])
);
const dailyTimeSlots = (
salonOpensAt,
salonClosesAt
) => {
const totalSlots =
(salonClosesAt – salonOpensAt) * 2;
const startTime = new Date()
.setHours(salonOpensAt, 0, 0, 0);
const increment = 30 * 60 * 1000;
return timeIncrements(
totalSlots,
startTime,
increment
);
};
const toTimeValue = timestamp =>
new Date(timestamp).toTimeString().substring(0, 5);
const TimeSlotTable = ({
salonOpensAt,
salonClosesAt
}) => {
const timeSlots = dailyTimeSlots(
salonOpensAt,
salonClosesAt);
return (
<table id="time-slots">
<tbody>
{timeSlots.map(timeSlot => (
<tr key={timeSlot}>
<th>{toTimeValue(timeSlot)}</th>
</tr>
))}
</tbody>
</table>
);
};
export const AppointmentForm = ({
original,
selectableServices,
service,
salonOpensAt,
salonClosesAt
}) => (
<form>
...
<TimeSlotTable
salonOpensAt={salonOpensAt}
salonClosesAt={salonClosesAt} />
</form>
);
AppointmentForm.defaultProps = {
salonOpensAt: 9,
salonClosesAt: 19,
selectableServices: [ ... ]
};
That’s all there is to adding the left-hand side column of headings.
Now, what about the column headings? In this section, we’ll create a new top row that contains these cells, making sure to leave an empty cell in the top-left corner, since the left column contains the time headings and not data. Follow these steps:
it("renders an empty cell at the start of the header row", () =>
render(
<AppointmentForm original={blankAppointment} />
);
const headerRow = element("thead > tr");
expect(headerRow.firstChild).toContainText("");
});
<table id="time-slots">
<thead>
<tr>
<th />
</tr>
</thead>
<tbody>
...
</tbody>
</table>
it("renders a week of available dates", () => {
const specificDate = new Date(2018, 11, 1);
render(
<AppointmentForm
original={blankAppointment}
today={specificDate}
/>
);
const dates = elements(
"thead >* th:not(:first-child)"
);
expect(dates).toHaveLength(7);
expect(dates[0]).toContainText("Sat 01");
expect(dates[1]).toContainText("Sun 02");
expect(dates[6]).toContainText("Fri 07");
});
Why pass a date into the component?
When you’re testing a component that deals with dates and times, you almost always want a way to control the time values that the component will see, as we have in this test. You’ll rarely want to just use the real-world time because that can cause intermittent failures in the future. For example, your test may assume that a month has at least 30 days in the year, which is only true for 11 out of 12 months. It’s better to fix the month to a specific month rather than have an unexpected failure when February comes around.
For an in-depth discussion on this topic, take a look at https://reacttdd.com/controlling-time.
const weeklyDateValues = (startDate) => {
const midnight = startDate.setHours(0, 0, 0, 0);
const increment = 24 * 60 * 60 * 1000;
return timeIncrements(7, midnight, increment);
};
const toShortDate = (timestamp) => {
const [day, , dayOfMonth] = new Date(timestamp)
.toDateString()
.split(" ");
return `${day} ${dayOfMonth}`;
};
const TimeSlotTable = ({
salonOpensAt,
salonClosesAt,
today
}) => {
const dates = weeklyDateValues(today);
...
return (
<table id="time-slots">
<thead>
<tr>
<th />
{dates.map(d => (
<th key={d}>{toShortDate(d)}</th>
))}
</tr>
</thead>
...
</table>
)
};
export const AppointmentForm = ({
original,
selectableServices,
service,
salonOpensAt,
salonClosesAt,
today
}) => {
...
return <form>
<TimeSlotTable
...
salonOpensAt={salonOpensAt}
salonClosesAt={salonClosesAt}
today={today}
/>
</form>;
};
AppointmentForm.defaultProps = {
today: new Date(),
...
}
With that, we’re done with our table layout. You’ve seen how to write tests that specify the table structure itself and fill in both a header column and a header row. In the next section, we’ll fill in the table cells with radio buttons.
Now that we have our table with headings in place, it’s time to add radio buttons to each of the table cells. Not all cells will have radio buttons – only those that represent an available time slot will have a radio button.
This means we’ll need to pass in another new prop to AppointmentForm that will help us determine which time slots to show. This prop is availableTimeSlots, which is an array of objects that list times that are still available. Follow these steps:
it("renders radio buttons in the correct table cell positions", () => {
const oneDayInMs = 24 * 60 * 60 * 1000;
const today = new Date();
const tomorrow = new Date(
today.getTime() + oneDayInMs
);
const availableTimeSlots = [
{ startsAt: today.setHours(9, 0, 0, 0) },
{ startsAt: today.setHours(9, 30, 0, 0) },
{ startsAt: tomorrow.setHours(9, 30, 0, 0) },
];
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={availableTimeSlots}
today={today}
/>
);
expect(cellsWithRadioButtons()).toEqual([0, 7, 8]);
});
const cellsWithRadioButtons = () =>
elements("input[type=radio]").map((el) =>
elements("td").indexOf(el.parentNode)
);
{timeSlots.map(timeSlot =>
<tr key={timeSlot}>
<th>{toTimeValue(timeSlot)}</th>
{dates.map(date => (
<td key={date}>
<input type="radio" />
</td>
))}
</tr>
)}
At this point, your test will be passing.
We didn’t need to use availableTimeSlots in our production code, even though our tests require it! Instead, we just put a radio button in every cell! This is obviously “broken.” However, if you think back to our rule of only ever implementing the simplest thing that will make the test pass, then it makes sense. What we need now is another test to prove the opposite – that certain radio buttons do not exist, given availableTimeSlots.
How can we get to the right implementation? We can do this by testing that having no available time slots renders no radio buttons at all:
it("does not render radio buttons for unavailable time slots", () => {
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={[]}
/>
);
expect(
elements("input[type=radio]")
).toHaveLength(0);
});
const mergeDateAndTime = (date, timeSlot) => {
const time = new Date(timeSlot);
return new Date(date).setHours(
time.getHours(),
time.getMinutes(),
time.getSeconds(),
time.getMilliseconds()
);
};
const TimeSlotTable = ({
salonOpensAt,
salonClosesAt,
today,
availableTimeSlots
}) => {
...
};
{dates.map(date =>
<td key={date}>
{availableTimeSlots.some(availableTimeSlot =>
availableTimeSlot.startsAt === mergeDateAndTime(date, timeSlot)
)
? <input type="radio" />
: null
}
</td>
)}
export const AppointmentForm = ({
original,
selectableServices,
service,
salonOpensAt,
salonClosesAt,
today,
availableTimeSlots
}) => {
...
return (
<form>
...
<TimeSlotTable
salonOpensAt={salonOpensAt}
salonClosesAt={salonClosesAt}
today={today}
availableTimeSlots={availableTimeSlots} />
</form>
);
};
describe("AppointmentForm", () => {
const today = new Date();
const availableTimeSlots = [
{ startsAt: today.setHours(9, 0, 0, 0) },
{ startsAt: today.setHours(9, 30, 0, 0) },
];
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={availableTimeSlots}
/>
);
Handling sensible defaults for props
Adding a default value for a new prop in every single test is no one’s idea of fun. Later in this chapter you'll learn how to avoid prop explosion in your tests by introducing a testProps object to group sensible default prop values.
it("sets radio button values to the startsAt value of the corresponding appointment", () => {
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={availableTimeSlots}
today={today}
/>
);
const allRadioValues = elements(
"input[type=radio]"
).map(({ value }) => parseInt(value));
const allSlotTimes = availableTimeSlots.map(
({ startsAt }) => startsAt
);
expect(allRadioValues).toEqual(allSlotTimes);
});
Defining constants within tests
Sometimes, it’s preferable to keep constants within a test rather than pulling them out as helpers. In this case, these helpers are only used by this one test and are very specific in what they do. Keeping them inline helps you understand what the functions are doing without having to search through the file for the function definitions.
const RadioButtonIfAvailable = ({
availableTimeSlots,
date,
timeSlot,
}) => {
const startsAt = mergeDateAndTime(date, timeSlot);
if (
availableTimeSlots.some(
(timeSlot) => timeSlot.startsAt === startsAt
)
) {
return (
<input
name="startsAt"
type="radio"
value={startsAt}
/>
);
}
return null;
};
The name property
Radio buttons with the same name attribute are part of the same group. Clicking one radio button will check that button and uncheck all others in the group.
{dates.map(date =>
<td key={date}>
<RadioButtonIfAvailable
availableTimeSlots={availableTimeSlots}
date={date}
timeSlot={timeSlot}
/>
</td>
)}
Now that you’ve got the radio buttons displaying correctly, it’s time to give them some behavior.
Let’s see how we can use the checked property on the input element to ensure we set the right initial value for our radio button.
For this, we’ll use a helper called startsAtField that takes an index and returns the radio button at that position. To do that, the radio buttons must all be given the same name. This joins the radio button into a group, which means only one can be selected at a time. Follow these steps:
const startsAtField = (index) =>
elements("input[name=startsAt]")[index];
it("pre-selects the existing value", () => {
const appointment = {
startsAt: availableTimeSlots[1].startsAt,
};
render(
<AppointmentForm
original={appointment}
availableTimeSlots={availableTimeSlots}
today={today}
/>
);
expect(startsAtField(1).checked).toEqual(true);
});
<TimeSlotTable
salonOpensAt={salonOpensAt}
salonClosesAt={salonClosesAt}
today={today
availableTimeSlots={availableTimeSlots}
checkedTimeSlot={appointment.startsAt}
/>
const TimeSlotTable = ({
...,
checkedTimeSlot,
}) => {
...
<RadioButtonIfAvailable
availableTimeSlots={availableTimeSlots}
date={date}
timeSlot={timeSlot}
checkedTimeSlot={checkedTimeSlot}
/>
...
};
const RadioButtonIfAvailable = ({
...,
checkedTimeSlot,
}) => {
const startsAt = mergeDateAndTime(date, timeSlot);
if (
availableTimeSlots.some(
(a) => a.startsAt === startsAt
)
) {
const isChecked = startsAt === checkedTimeSlot;
return (
<input
name="startsAt"
type="radio"
value={startsAt}
checked={isChecked}
/>
);
}
return null;
};
That’s it for setting the initial value. Next, we’ll hook up the component with the onChange behavior.
Throughout this chapter, we have slowly built up a component hierarchy: AppointmentForm renders a TimeSlotTable component that renders a bunch of RadioButtonIfAvailable components that may (or may not) render the radio button input elements.
The final challenge involves how to take an onChange event from the input element and pass it back up to AppointmentForm, which will control the appointment object.
The code in this section will make use of the useCallback hook. This is a form of performance optimization: we can’t write a test to specify that this behavior exists. A good rule of thumb is that if you’re passing functions through as props, then you should consider using useCallback.
The useCallback hook
The useCallback hook returns a memoized callback. This means you always get the same reference back each time it’s called, rather than a new constant with a new reference. Without this, child components that are passed the callback as a prop (such as TimeSlotTable) would re-render each time the parent re-renders, because the different reference would cause it to believe that a re-render was required.
Event handlers on input elements don’t need to use useCallback because event handler props are handled centrally; changes to those props do not require re-renders.
The second parameter to useCallback is the set of dependencies that will cause useCallback to update. In this case, it’s [], an empty array, because it isn’t dependent on any props or other functions that may change. Parameters to the function such as target don’t count, and setAppointment is a function that is guaranteed to remain constant across re-renders.
See the Further reading section at the end of this chapter for a link to more information on useCallback.
Since we haven’t done any work on submitting AppointmentForm yet, we need to start there. Let’s add a test for the form’s submit button:
it("renders a submit button", () => {
render(
<AppointmentForm original={blankAppointment} />
);
expect(submitButton()).not.toBeNull();
});
import {
initializeReactContainer,
render,
field,
form,
element,
elements,
submitButton,
} from "./reactTestExtensions";
<form>
...
<input type="submit" value="Add" />
</form>
it("saves existing value when submitted", () => {
expect.hasAssertions();
const appointment = {
startsAt: availableTimeSlots[1].startsAt,
};
render(
<AppointmentForm
original={appointment}
availableTimeSlots={availableTimeSlots}
today={today}
onSubmit={({ startsAt }) =>
expect(startsAt).toEqual(
availableTimeSlots[1].startsAt
)
}
/>
);
click(submitButton());
});
import {
initializeReactContainer,
render,
field,
form,
element,
elements,
submitButton,
click,
} from "./reactTestExtensions";
export const AppointmentForm = ({
...,
onSubmit,
}) => {
const handleSubmit = (event) => {
event.preventDefault();
onSubmit(original);
};
return (
<form onSubmit={handleSubmit}>
...
</form>
);
};
it("saves new value when submitted", () => {
expect.hasAssertions();
const appointment = {
startsAt: availableTimeSlots[0].startsAt,
};
render(
<AppointmentForm
original={appointment}
availableTimeSlots={availableTimeSlots}
today={today}
onSubmit={({ startsAt }) =>
expect(startsAt).toEqual(
availableTimeSlots[1].startsAt
)
}
/>
);
click(startsAtField(1));
click(submitButton());
});
import React, { useState, useCallback } from "react";
export const AppointmentForm = ({
...
}) => {
const [appointment, setAppointment] =
useState(original);
...
return (
<form>
...
<TimeSlotTable
...
checkedTimeSlot={appointment.startsAt}
/>
...
</form>
);
};
const handleSubmit = (event) => {
event.preventDefault();
onSubmit(appointment);
};
The call to preventDefault
I’m avoiding writing the test for preventDefault since we’ve covered it previously. In a real application, I would almost certainly add that test again.
const handleStartsAtChange = useCallback(
({ target: { value } }) =>
setAppointment((appointment) => ({
...appointment,
startsAt: parseInt(value),
})),
[]
);
<TimeSlotTable
salonOpensAt={salonOpensAt}
salonClosesAt={salonClosesAt}
today={today}
availableTimeSlots={availableTimeSlots}
checkedTimeSlot={appointment.startsAt}
handleChange={handleStartsAtChange}
/>
const TimeSlotTable = ({
...,
handleChange,
}) => {
...,
<RadioButtonIfAvailable
availableTimeSlots={availableTimeSlots}
date={date}
timeSlot={timeSlot}
checkedTimeSlot={checkedTimeSlot}
handleChange={handleChange}
/>
...
};
const RadioButtonIfAvailable = ({
availableTimeSlots,
date,
timeSlot,
checkedTimeSlot,
handleChange
}) => {
...
return (
<input
name="startsAt"
type="radio"
value={startsAt}
checked={isChecked}
onChange={handleChange}
/>
);
...
};
At this point, your test should pass, and your time slot table should be fully functional.
This section has covered a great deal of code: conditionally rendering input elements, as well as details of radio button elements, such as giving a group name and using the onChecked prop, and then passing its onChange event through a hierarchy of components.
This is a good moment to manually test what you’ve built. You’ll need to update src/index.js so that it loads AppointmentForm, together with sample data. These changes are included in the Chapter05/Complete directory:
Figure 5.2 – AppointmentForm on show
You’ve now completed the work required to build the radio button table. Now it’s time to refactor.
Let’s look at a couple of simple ways to reduce the amount of time and code needed for test suites like the one we’ve just built: first, extracting builder functions, and second, extracting objects to store sensible defaults for our component props.
You’ve already seen how we can extract reusable functions into namespaces of their own, such as the render, click, and element DOM functions. A special case of this is the builder function, which constructs objects that you’ll use in the Arrange and Act phases of your test.
The purpose of these functions is not just to remove duplication but also for simplification and to aid with comprehension.
We already have one candidate in our test suite, which is the following code:
const today = new Date(); today.setHours(9, 0, 0, 0);
We’ll update our test suite so that it uses a builder function called todayAt, which will save a bit of typing:
todayAt(9);
We’ll also extract the today value as a constant as we’ll also make use of that.
Builders for domain objects
Most often, you’ll create builder functions for the domain objects in your code base. In our case, that would be customer or appointment objects, or even the time slot objects with the single startsAt field. Our code base hasn’t progressed enough to warrant this, so we’ll start with builders for the Date objects that we’re using. We’ll write more builders later in this book.
Let’s get started:
export const today = new Date();
import { today } from "./builders/time";
export const todayAt = (
hours,
minutes = 0,
seconds = 0,
milliseconds = 0
) =>
new Date(today).setHours(
hours,
minutes,
seconds,
milliseconds
);
Immutability of builder functions
If your namespaces use shared constant values, like we’re doing with today here, make sure your functions don’t inadvertently mutate them.
import { today, todayAt } from "./builders/time";
today.setHours(9, 0, 0, 0)
Replace it with the following:
todayAt(9)
Replace it with the following:
todayAt(9, 30)
const oneDayInMs = 24 * 60 * 60 * 1000;
const tomorrow = new Date(
today.getTime() + oneDayInMs
);
export const tomorrowAt = (
hours,
minutes = 0,
seconds = 0,
milliseconds = 0
) =>
new Date(tomorrow).setHours(
hours,
minutes,
seconds,
milliseconds
);
import {
today,
todayAt,
tomorrowAt
} from "./builders/time";
tomorrow.setHours(9, 30, 0, 0)
Replace it with the following code:
tomorrowAt(9, 30)
We’ll make use of these helpers again in Chapter 7, Testing useEffect and Mocking Components. However, there’s one more extraction we can do before we finish with this chapter.
A test props object is an object that sets sensible defaults for props that you can use to reduce the size of your render statements. For example, look at the following render call:
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={availableTimeSlots}
today={today}
/>
);
Depending on the test, some (or all) of these props may be irrelevant to the test. The original prop is necessary so that our render function doesn’t blow up when rendering existing field values. But if our test is checking that we show a label on the page, we don’t care about that – and that’s one reason we created the blankAppointment constant. Similarly, availableTimeSlots and the today prop may not be relevant to a test.
Not only that, but often, our components can end up needing a whole lot of props that are necessary for a test to function. This can end up making your tests extremely verbose.
Too many props?
The technique you’re about to see is one way of dealing with many required props. But having a lot of props (say, more than four or five) might be a hint that the design of your components can be improved. Can the props be joined into a complex type? Or should the component be split into two or more components?
This is another example of listening to your tests. If the tests are difficult to write, take a step back and look at your component design.
We can define an object named testProps that exists at the top of our describe block:
const testProps = {
original: { ... },
availableTimeSlots: [ ... ],
today: ...
}
This can then be used in the render call, like this:
render(<AppointmentForm {...testProps} />);
If the test does depend on a prop, such as if its expectation mentions part of the props value, then you shouldn’t rely on the hidden-away value in the testProps object. Those values are sensible defaults. The values in your test should be prominently displayed, as in this example:
const appointment = {
...blankAppointment,
service: "Blow-dry"
};
render(
<AppointmentForm {...testProps} original={appointment} />
);
const option = findOption(field("service"), "Blow-dry");
expect(option.selected).toBe(true);
Notice how the original prop is still included in the render call after testProps.
Sometimes, you’ll want to explicitly include a prop, even if the value is the same as the testProps value. That’s to highlight its use within the test. We’ll see an example of that in this section.
When to use an explicit prop
As a rule of thumb, if the prop is used in your test assertions, or if the prop’s value is crucial for the scenario the test is testing, then the prop should be included explicitly in the render call, even if its value is the same as the value defined in testProps.
Let’s update the AppointmentForm test suite so that it uses a testProps object:
const testProps = {
today,
selectableServices: services,
availableTimeSlots,
original: blankAppointment,
};
it("renders a form", () => {
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={availableTimeSlots}
/>
);
expect(form()).not.toBeNull();
});
This can be updated to look as follows:
it("renders a form", () => {
render(<AppointmentForm {...testProps} />);
expect(form()).not.toBeNull();
});
it("has a blank value as the first value", () => {
render(
<AppointmentForm
original={blankAppointment}
availableTimeSlots={availableTimeSlots}
/>
);
const firstOption = field("service").childNodes[0];
expect(firstOption.value).toEqual("");
});
Since this test depends on having a blank value passed in for the service field, let’s keep the original prop there:
it("has a blank value as the first value", () => {
render(
<AppointmentForm
{...testProps}
original={blankAppointment}
/>
);
const firstOption = field("service").childNodes[0];
expect(firstOption.value).toEqual("");
});
We’ve effectively hidden the availableTimeSlots property, which was noise before.
it("lists all salon services", () => {
const services = ["Cut", "Blow-dry"];
render(
<AppointmentForm
original={blankAppointment}
selectableServices={services}
availableTimeSlots={availableTimeSlots}
/>
);
expect(
labelsOfAllOptions(field("service"))
).toEqual(expect.arrayContaining(services));
});
This test uses the services constant in its expectation, so this is a sign that we need to keep that as an explicit prop. Change it so that it matches the following:
it("lists all salon services", () => {
const services = ["Cut", "Blow-dry"];
render(
<AppointmentForm
{...testProps}
selectableServices={services}
/>
);
expect(
labelsOfAllOptions(field("service"))
).toEqual(expect.arrayContaining(services));
});
it("pre-selects the existing value", () => {
const services = ["Cut", "Blow-dry"];
const appointment = { service: "Blow-dry" };
render(
<AppointmentForm
{...testProps}
original={appointment}
selectableServices={services}
/>
);
const option = findOption(
field("service"),
"Blow-dry"
);
expect(option.selected).toBe(true);
});
The remaining tests in this test suite are in the nested describe block for the time slot table. Updating this is left as an exercise for you.
You’ve now learned yet more ways to clean up your test suites: extracting test data builders and extracting a testProps object. Remember that using the testProps object isn’t always the right thing to do; it may be better to refactor your component so that it takes fewer props.
In this chapter, you learned how to use two types of HTML form elements: select boxes and radio buttons.
The component we’ve built has a decent amount of complexity, mainly due to the component hierarchy that’s used to display a calendar view, but also because of the date and time functions we’ve needed to help display that view.
That is about as complex as it gets: writing React component tests shouldn’t feel any more difficult than it has in this chapter.
Taking a moment to review our tests, the biggest issue we have is the use of expect.hasAssertions and the unusual Arrange-Assert-Act order. In Chapter 6, Exploring Test Doubles, we’ll discover how we can simplify these tests and get them back into Arrange-Act-Assert order.
The following are some exercises for you to try out:
expect(field("service")).toBeElementWithTag("select");
These tests are practically the same as they were for CustomerForm, including the use of the change helper. If you want a challenge, you can try extracting these form test helpers into a module of their own that is shared between CustomerForm and AppointmentForm.
The useCallback hook is useful when you’re passing event handlers through a hierarchy of components. Take a look at the React documentation for tips on how to ensure correct usage: https://reactjs.org/docs/hooks-reference.html#usecallback.
In this chapter, we’ll look at the most involved piece of the TDD puzzle: test doubles.
Jest has a set of convenience functions for test doubles, such as jest.spyOn and jest.fn. Unfortunately, using test doubles well is a bit of a dark art. If you don’t know what you’re doing, you can end up with complicated, brittle tests. Maybe this is why Jest doesn’t promote them as a first-class feature of its framework.
Don’t be turned off: test doubles are a highly effective and versatile tool. The trick is to restrict your usage to a small set of well-defined patterns, which you’ll learn about in the next few chapters.
In this chapter, we will build our own set of hand-crafted test double functions. They work pretty much just how Jest functions do, but with a simpler (and more clunky) interface. The aim is to take the magic out of these functions, showing you how they are built and how they can be used to simplify your tests.
In the test suites you’ve built so far, some tests didn’t use the normal Arrange-Act-Assert (AAA) test format. These are the tests that start with expect.hasAssertions. In a real code base, I would always avoid using this function and instead use test doubles, which help reorder the test into AAA order. We’ll start there: refactoring our existing tests to use our hand-crafted test doubles, and then swapping them out for Jest’s own test double functions.
The following topics will be covered in this chapter:
By the end of the chapter, you’ll have learned how to make effective use of Jest’s test double functionality.
The code files for this chapter can be found here:
The code samples for this chapter and beyond contain extra commits that add a working backend to the application. This allows you to make requests to fetch data, which you’ll start doing in this chapter.
In the companion code repository, from Chapter06/Start onward, the npm run build command will automatically build the server.
You can then start the application by using the npm run serve command and browsing to http://localhost:3000 or http://127.0.0.1:3000.
If you run into problems
Check out the Troubleshooting section of the repository’s README.md file if you’re not able to get the application running.
A unit in unit testing refers to a single function or component that we focus on for the duration of that test. The Act phase of a test should involve just one action on one unit. But units don’t act in isolation: functions call other functions, and components render child components and call callback props passed in from parent components. Your application can be thought of as a web of dependencies, and test doubles help us to design and test those dependencies.
When we’re writing tests, we isolate the unit under test. Often, that means we avoid exercising any of the collaborating objects. Why? Firstly, it helps us work toward our goal of independent, laser-focused tests. Secondly, sometimes those collaborating objects have side effects that would complicate our tests.
To give one example, with React components, we sometimes want to avoid rendering child components because they perform network requests when they are mounted.
A test double is an object that acts in place of a collaborating object. In Chapter 4, Test-Driving Data Input, you saw an example of a collaborator: the onSubmit function, which is a prop passed to both CustomerForm and AppointmentForm. We can swap that out with a test double in our tests. As we’ll see, that helps us define the relationship between the two.
The most important place to use test doubles is at the edges of our system when our code interacts with anything external to the page content: HyperText Transfer Protocol (HTTP) requests, filesystem access, sockets, local storage, and so on.
Test doubles are categorized into several different types: spies, stubs, mocks, dummies, and fakes. We normally only use the first two, and that’s what we’ll concentrate on in this chapter.
A fake is any test double that has any kind of logic or control structure within it, such as conditional statements or loops. Other types of test objects, such as spies and stubs, are made up entirely of variable assignments and function calls.
One type of fake you’ll see is an in-memory repository. You can use this in place of Structured Query Language (SQL) data stores, message brokers, and other complex sources of data.
Fakes are useful when testing complex collaborations between two units. We’ll often start by using spies and stubs and then refactor to a fake once the code starts to feel unwieldy. A single fake can cover a whole set of tests, which is simpler than maintaining a whole bunch of spies and stubs.
We avoid fakes for these reasons:
Now that we’ve covered the theory of test doubles, let’s move on to using them in our code.
In this section, you’ll hand-craft a reusable spy function and adjust your tests to get them back into AAA order.
Here’s a reminder of how one of those tests looked, from the CustomerForm test suite. It’s complicated by the fact it’s wrapped in a test generator, but you can ignore that bit for now—it’s the test content that’s important:
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
expect.hasAssertions();
const customer = { [fieldName]: value };
render(
<CustomerForm
original={customer}
onSubmit={(props) =>
expect(props[fieldName]).toEqual(value)
}
/>
);
click(submitButton());
});
There are a couple of issues with this code, as follows:
We can fix both issues by building a spy.
What is a spy?
A spy is a type of test double that records the arguments it is called with so that those values can be inspected later.
To move the expectation under the Act phase of the test, we can introduce a variable to store the firstName value that’s passed into the onSubmit function. We then write the expectation against that stored value.
Let’s do that now, as follows:
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
let submitArg;
const customer = { [fieldName]: value };
render(
<CustomerForm
original={customer}
onSubmit={submittedCustomer => (
submitArg = submittedCustomer
)}
/>
);
click(submitButton());
expect(submitArg).toEqual(customer);
});
The submitArg variable is assigned within our onSubmit handler and then asserted in the very last line of the test. This fixes both the issues we had with the first test: our test is back in AAA order and we got rid of the ugly expect.hasAssertions() call.
<form id="customer" onSubmit={handleSubmit}>
Remove the onSubmit prop entirely, like so:
<form id="customer">
it.only("saves existing value when submitted", () => {
FAIL test/CustomerForm.test.js
● CustomerForm › first name field › saves existing value when submitted
expect(received).toEqual(expected) // deep equality
Expected: {"firstName": "existingValue"}
Received: undefined
The code you’ve written in this test shows the essence of the spy function: we set a variable when the spy is called, and then we write an expectation based on that variable value.
But we don’t yet have an actual spy function. We’ll create that next.
We still have other tests within both CustomerForm and AppointmentForm that use the expect.hasAssertions form. How can we reuse what we’ve built in this one test across everything else? We can create a generalized spy function that can be used any time we want spy functionality.
Let’s start by defining a function that can stand in for any single-argument function, such as the event handlers we would pass to the onSubmit form prop, as follows:
const singleArgumentSpy = () => {
let receivedArgument;
return {
fn: arg => (receivedArgument = arg),
receivedArgument: () => receivedArgument
};
};
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
const submitSpy = singleArgumentSpy();
const customer = { [fieldName]: value };
render(
<CustomerForm
original={customer}
onSubmit={submitSpy.fn}
/>
);
click(submitButton());
expect(submitSpy.receivedArgument()).toEqual(
customer
);
});
const spy = () => {
let receivedArguments;
return {
fn: (...args) => (receivedArguments = args),
receivedArguments: () => receivedArguments,
receivedArgument: n => receivedArguments[n]
};
};
This uses parameter destructuring to save an entire array of parameters. We can use receivedArguments to return that array or use receivedArgument(n) to retrieve the nth argument.
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
const submitSpy = spy();
const customer = { [fieldName]: value };
render(
<CustomerForm
original={customer}
onSubmit={submitSpy.fn}
/>
);
click(submitButton());
expect(
submitSpy.receivedArguments()
).toBeDefined();
expect(submitSpy.receivedArgument(0)).toEqual(
customer
);
});
That’s really all there is to a spy: it’s just there to keep track of when it was called, and the arguments it was called with.
Let’s write a matcher that encapsulates these expectations into one single statement, like this:
expect(submitSpy).toBeCalledWith(value);
This is more descriptive than using a toBeDefined() argument on the matcher. It also encapsulates the notion that if receivedArguments hasn’t been set, then it hasn’t been called.
Throwaway code
We’ll spike this code—in other words, we won’t write tests. That’s because soon, we’ll replace this with Jest’s own built-in spy functionality. There’s no point in going too deep into a “real” implementation since we’re not intending to keep it around for long.
We’ll start by replacing the functionality of the first expectation, as follows:
expect.extend({
toBeCalled(received) {
if (received.receivedArguments() === undefined) {
return {
pass: false,
message: () => "Spy was not called.",
};
}
return {
pass: true,
message: () => "Spy was called.",
};
},
});
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
const submitSpy = spy();
const customer = { [fieldName]: value };
render(
<CustomerForm
original={customer}
onSubmit={submitSpy.fn}
/>
);
click(submitButton());
expect(submitSpy).toBeCalled(customer);
expect(submitSpy.receivedArgument(0)).toEqual(
customer
);
});
expect.extend({
toBeCalledWith(received, ...expectedArguments) {
if (received.receivedArguments() === undefined) {
...
}
const notMatch = !this.equals(
received.receivedArguments(),
expectedArguments
);
if (notMatch) {
return {
pass: false,
message: () =>
"Spy called with the wrong arguments: " +
received.receivedArguments() +
".",
};
}
return ...;
},
});
Using this.equals in a matcher
The this.equals method is a special type of equality function that can be used in matchers. It does deep equality matching, meaning it will recurse through hashes and arrays looking for differences. It also allows the use of expect.anything(), expect.objectContaining(), and expect.arrayContaining() special functions.
If you were test-driving this matcher and had extracted it into its own file, you wouldn’t use this.equals. Instead, you’d import the equals function from the @jest/expect-utils package. We’ll do this in Chapter 7, Testing useEffect and Mocking Components.
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
...
click(submitButton());
expect(submitSpy).toBeCalledWith(customer);
});
This completes our spy implementation, and you’ve seen how to test callback props. Next, we’ll look at spying on a more difficult function: global.fetch.
In this section, we’ll use the Fetch API to send customer data to our backend service. We already have an onSubmit prop that is called when the form is submitted. We’ll morph this onSubmit call into a global.fetch call, in the process of adjusting our existing tests.
In our updated component, when the Submit button is clicked, a POST HTTP request is sent to the /customers endpoint via the fetch function. The body of the request will be a JavaScript Object Notation (JSON) object representation of our customer.
The server implementation that’s included in the GitHub repository will return an updated customer object with an additional field: the customer id value.
If the fetch request is successful, we’ll call a new onSave callback prop with the fetch response. If the request isn’t successful, onSave won’t be called and we’ll instead render an error message.
You can think of fetch as a more advanced form on onSubmit: both are functions that we’ll call with our customer object. But fetch needs a special set of parameters to define the HTTP request being made. It also returns a Promise object, so we’ll need to account for that, and the request body needs to be a string, rather than a plain object, so we’ll need to make sure we translate it in our component and in our test suite.
One final difference: fetch is a global function, accessible via global.fetch. We don’t need to pass that as a prop. In order to spy on it, we replace the original function with our spy.
Understanding the Fetch API
The following code samples show how the fetch function expects to be called. If you’re unfamiliar with this function, see the Further reading section at the end of this chapter.
With all that in mind, we can plan our route forward: we’ll start by replacing the global function with our own spy, then we’ll add new tests to ensure we call it correctly, and finally, we’ll update our onSubmit tests to adjust its existing behavior.
We’ve seen how to spy on a callback prop, by simply passing the spy as the callback’s prop value. To spy on a global function, we simply overwrite its value before our test runs and reset it back to the original function afterward.
Since global.fetch is a required dependency of your component—it won’t function without it—it makes sense to set a default spy in the test suite’s beforeEach block so that the spy is primed in all tests. The beforeEach block is also a good place for setting default return values of stubs, which we’ll do a little later in the chapter.
Follow these steps to set a default spy on global.fetch for your test suite:
describe("CustomerForm", () => {
const originalFetch = global.fetch;
let fetchSpy;
...
})
The originalFetch constant will be used when restoring the spy after our tests are complete. The fetchSpy variable will be used to store our fetch object so that we can write expectations against it.
beforeEach(() => {
initializeReactContainer();
fetchSpy = spy();
global.fetch = fetchSpy.fn;
});
afterEach(() => {
global.fetch = originalFetch;
});
Resetting global spies with original values
It’s important to reset any global variables that you replace with spies. This is a common cause of test interdependence: with a “dirty” spy, one test may break because some other test failed to reset its spies.
In this specific case, the Node.js runtime environment doesn’t actually have a global.fetch function, so the originalFetch constant will end up as undefined. You could argue, then, that this is unnecessary: in our afterEach block, we could simply delete the fetch property from global instead.
Later in the chapter, we’ll modify our approach to setting global spies when we use Jest’s built-in spy functions.
With the global spy in place, you’re ready to make use of it in your tests.
It’s time to add global.fetch to our component. When the submit button is clicked, we want to check that global.fetch is called with the right arguments. Similar to how we tested onSubmit, we’ll split this into a test for each field, specifying that each field must be passed along.
It turns out that global.fetch needs a whole bunch of parameters passed to it. Rather than test them all in one single unit test, we’re going to split up the tests according to their meaning.
We’ll start by checking the basics of the request: that it’s a POST request to the /customers endpoint. Follow these steps:
it("sends request to POST /customers when submitting the form", () => {
render(
<CustomerForm
original={blankCustomer}
onSubmit={() => {}}
/>
);
click(submitButton());
expect(fetchSpy).toBeCalledWith(
"/customers",
expect.objectContaining({
method: "POST",
})
);
});
● CustomerForm › sends request to POST /customers when submitting the form
Spy was not called.
163 | );
164 | click(submitButton());
> 165 | expect(fetchSpy).toBeCalledWith(
| ^
166 | "/customers",
167 | expect.objectContaining({
168 | method: "POST",
const handleSubmit = (event) => {
event.preventDefault();
global.fetch("/customers", {
method: "POST",
});
onSubmit(customer);
};
Side-by-side implementations
This is a side-by-side implementation. We leave the “old” implementation—the call to onSubmit—in place so that the other tests continue to pass.
it("calls fetch with the right configuration", () => {
render(
<CustomerForm
original={blankCustomer}
onSubmit={() => {}}
/>
);
click(submitButton());
expect(fetchSpy).toBeCalledWith(
expect.anything(),
expect.objectContaining({
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
})
);
});
Testing a subset of properties with expect.anything and expect.objectContaining
The expect.anything function is a useful way of saying: “I don’t care about this argument in this test; I’ve tested it somewhere else.” It’s another great way of keeping your tests independent of each other. In this case, our previous test checks that the first parameter is set to /customers, so we don’t need to test that again in this test.
The expect.objectContaining function is just like expect.arrayContaining, and allows us to test just a slice of the full argument value.
● CustomerForm › calls fetch with the right configuration when submitting the form
Spy was called with the wrong arguments: /customers,[object Object].
const handleSubmit = (event) => {
event.preventDefault();
global.fetch("/customers", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
});
onSubmit(customer);
};
That gets the plumbing in place for our global.fetch call, with each of the constant arguments defined and in its place. Next, we’ll add in the dynamic argument: the request body.
You’ve already started to build out the side-be-side implementation by using new tests. Now, it’s time to rework the existing tests. We’ll remove the old implementation (onSubmit, in this case) and replace it with the new implementation (global.fetch).
Once we’ve completed that, all the tests will point to global.fetch and so we can update our implementation to remove the onSubmit call from the handleSubmit function.
We’ve got two tests to update: the test that checks submitting existing values, and the test that checks submitting new values. They are complicated by the fact that they are wrapped in test-generator functions. That means as we change them, we should expect all the generated tests—one for each field—to fail as a group. It’s not ideal, but the process we’re following would be the same even if it were just a plain test.
Let’s get started with the test you’ve already worked on in this chapter, for submitting existing values. Follow these steps:
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
const customer = { [fieldName]: value };
const submitSpy = spy();
render(
<CustomerForm
original={customer}
onSubmit={submitSpy.fn}
/>
);
click(submitButton());
expect(submitSpy).toBeCalledWith(customer);
expect(fetchSpy).toBeCalledWith(
expect.anything(),
expect.objectContaining({
body: JSON.stringify(customer),
})
);
});
const handleSubmit = (event) => {
event.preventDefault();
global.fetch("/customers", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(original),
});
onSubmit(customer);
};
const itSubmitsNewValue = (fieldName, value) =>
it("saves new value when submitted", () => {
...
expect(fetchSpy).toBeCalledWith(
expect.anything(),
expect.objectContaining({
body: JSON.stringify({
...blankCustomer,
[fieldName]: value,
}),
})
);
});
const handleSubmit = (event) => {
event.preventDefault();
global.fetch("/customers", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(customer),
});
onSubmit(customer);
};
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
const customer = { [fieldName]: value };
render(<CustomerForm original={customer} />);
click(submitButton());
expect(fetchSpy).toBeCalledWith(
expect.anything(),
expect.objectContaining({
body: JSON.stringify(customer),
})
);
});
const itSubmitsNewValue = (fieldName, value) =>
it("saves new value when submitted", () => {
render(<CustomerForm original={blankCustomer} />);
change(field(fieldName), value);
click(submitButton());
expect(fetchSpy).toBeCalledWith(
expect.anything(),
expect.objectContaining({
body: JSON.stringify({
...blankCustomer,
[fieldName]: value,
}),
})
);
});
You’ve now seen how you can continue your side-by-side implementation by reworking tests. Once all the tests are reworked, you can delete the original implementation.
Our tests have gotten pretty long-winded again. Let’s finish this section with a little cleanup.
When we’re writing expectations for our spies, we aren’t just limited to using the toBeCalledWith matcher. We can pull out arguments and give them names, and then use standard Jest matchers on them instead. This way, we can avoid all the ceremony with expect.anything and expect.objectContaining.
Let’s do that now. Proceed as follows:
const bodyOfLastFetchRequest = () =>
JSON.parse(fetchSpy.receivedArgument(1).body);
const itSubmitsExistingValue = (fieldName, value) =>
it("saves existing value when submitted", () => {
const customer = { [fieldName]: value };
render(<CustomerForm original={customer} />);
click(submitButton());
expect(bodyOfLastFetchRequest()).toMatchObject(
customer
);
});
const itSubmitsNewValue = (fieldName, value) =>
it("saves new value when submitted", () => {
render(<CustomerForm original={blankCustomer} />);
change(field(fieldName), value);
click(submitButton());
expect(bodyOfLastFetchRequest()).toMatchObject({
[fieldName]: value,
});
});
These tests are now looking very smart!
This has been a complicated change: we’ve replaced the onSubmit prop with a call to global.fetch. We did that by introducing a global spy in the beforeEach block and writing a side-by-side implementation while we reworked our tests.
In the next part of this chapter, we’ll add to our knowledge of spies, turning them into stubs.
As with many HTTP requests, our POST /customers endpoint returns data: it will return the customer object together with a newly generated identifier that the backend has chosen for us. Our application will make use of this by taking the new ID and sending it back to the parent component (although we won’t build this parent component until Chapter 8, Building an Application Component).
To do that, we’ll create a new CustomerForm prop, onSave, which will be called with the result of the fetch call.
But hold on—didn’t we just remove an onSubmit prop? Yes, but this isn’t the same thing. The original onSubmit prop received the form values submitted by the user. This onSave prop is going to receive the customer object from the server after a successful save.
To write tests for this new onSave prop, we’ll need to provide a stub value for global.fetch, which essentially says, “This is the return value of calling the POST /customers endpoint with global.fetch.”
What is a stub?
A stub is a test double that always returns the same value when it is invoked. You decide what this value is when you construct the stub.
In this section, we’ll upgrade our hand-crafted spy function so that it can also stub function return values. Then, we’ll use it to test the addition of the new onSave prop to CustomerForm. Finally, we’ll use it to display an error to the user if, for some reason, the server failed to save the new customer object.
A stub is different from a spy because it’s not interested in tracking the call history of the function being stubbed—it just cares about returning a single value.
However, it turns out that our existing tests that use spies will also need to stub values. That’s because as soon as we use the returned value in our production code, the spy must return something; otherwise, the test will break. So, all spies end up being stubs, too.
Since we already have a spy function, we can “upgrade” it so that it has the ability to stub values too. Here’s how we can do this:
let returnValue;
fn: (...args) => {
receivedArguments = args;
return returnValue;
},
stubReturnValue: value => returnValue = value
It’s as simple as that: your function is now both a spy and a stub. Let’s make use of it in our tests.
So far, the handleSubmit function causes a fetch request to be made, but it doesn’t do anything with the response. In particular, it doesn’t wait for a response; the fetch API is asynchronous and returns a promise. Once that promise resolves, we can do something with the data that’s returned.
The next tests we’ll write will specify what our component should do with the resolved data.
When we’re dealing with promises in React callbacks, we need to use the asynchronous form of act. It looks like this:
await act(async () => performAsyncAction());
The performAsyncAction function doesn’t necessarily need to return a promise; act will wait for the browser’s async task queue to complete before it returns.
The action may be a button click, form submission, or any kind of input field event. It could also be a component render that has a useEffect hook that performs some asynchronous side effects, such as loading data.
Now, we’ll use the asynchronous form of act to test that the fetch promise is awaited. Unfortunately, introducing async/await into our handleSubmit function will then require us to update all our submission tests to use the asynchronous form of act.
As usual, we start with the test. Proceed as follows:
const fetchResponseOk = (body) =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(body)
});
fetch return values
The ok property returns true if the HTTP response status code was in the 2xx range. Any other kind of response, such as 404 or 500, will cause ok to be false.
export const clickAndWait = async (element) =>
act(async () => click(element));
import {
...,
clickAndWait,
} from "./reactTestExtensions";
it("notifies onSave when form is submitted", async () => {
const customer = { id: 123 };
fetchSpy.stubReturnValue(fetchResponseOk(customer));
const saveSpy = spy();
render(
<CustomerForm
original={customer}
onSave={saveSpy.fn}
/>
);
await clickAndWait(submitButton());
expect(saveSpy).toBeCalledWith(customer);
});
export const CustomerForm = ({
original, onSave
}) => {
...
};
const handleSubmit = async (event) => {
event.preventDefault();
const result = await global.fetch(...);
const customerWithId = await result.json();
onSave(customerWithId);
};
beforeEach(() => {
...
fetchSpy.stubReturnValue(fetchResponseOk({}));
});
Dummy values in beforeEach blocks
When stubbing out global functions such as global.fetch, always set a default dummy value within your beforeEach block and then override it in individual tests that need specific stubbed values.
it("sends HTTP request to POST /customers when submitting data", async () => {
render(<CustomerForm original={blankCustomer} />);
await clickAndWait(submitButton());
...
});
export const submitAndWait = async (formElement) =>
act(async () => submit(formElement));
import {
...,
submitAndWait,
} from "./reactTestExtensions";
it("prevents the default action when submitting the form", async () => {
render(<CustomerForm original={blankCustomer} />);
const event = await submitAndWait(form());
expect(event.defaultPrevented).toBe(true);
});
it("calls fetch with correct configuration", async () => {
render(
<CustomerForm
original={blankCustomer}
onSave={() => {}}
/>
);
...
});
Introducing testProps objects when required props are added
The introduction of this onSave no-op function creates noise, which doesn’t help with the readability of our test. This would be a perfect opportunity to introduce a testProps object, as covered in Chapter 5, Adding Complex Form Interactions.
const fetchResponseError = () =>
Promise.resolve({ ok: false });
it("does not notify onSave if the POST request returns an error", async () => {
fetchSpy.stubReturnValue(fetchResponseError());
const saveSpy = spy();
render(
<CustomerForm
original={blankCustomer}
onSave={saveSpy.fn}
/>
);
await clickAndWait(submitButton());
expect(saveSpy).not.toBeCalledWith();
});
Negating toBeCalledWith
This expectation is not what we really want: this one would pass if we still called onSave but passed the wrong arguments—for example, if we wrote onSave(null). What we really want is .not.toBeCalled(), which will fail if onSave is called in any form. But we haven’t built that matcher. Later in the chapter, we’ll fix this expectation by moving to Jest’s built-in spy function.
const handleSubmit = async (event) => {
...
const result = ...;
if (result.ok) {
const customerWithId = await result.json();
onSave(customerWithId);
}
};
As you’ve seen, moving a component from synchronous to asynchronous behavior can really disrupt our test suites. The steps just outlined are fairly typical of the work needed when this happens.
Async component actions can cause misreported Jest test failures
If you’re ever surprised to see a test fail and you’re at a loss to explain why it’s failing, double-check all the tests in the test suite to ensure that you’ve used the async form of act when it’s needed. Jest won’t warn you when a test finishes with async tasks still to run, and since your tests are using a shared DOM document, those async tasks will affect the results of subsequent tests.
Those are the basics of dealing with async behavior in tests. Now, let’s add a little detail to our implementation.
Let’s display an error to the user if the fetch returns an ok value of false. This would occur if the HTTP status code returned was in the 4xx or 5xx range, although for our tests we won’t need to worry about the specific status code. Follow these steps:
it("renders an alert space", async () => {
render(<CustomerForm original={blankCustomer} />);
expect(element("[role=alert]")).not.toBeNull();
});
const Error = () => (
<p role="alert" />
);
<form>
<Error />
...
</form>
it("renders error message when fetch call fails", async () => {
fetchSpy.mockReturnValue(fetchResponseError());
render(<CustomerForm original={blankCustomer} />);
await clickAndWait(submitButton());
expect(element("[role=alert]")).toContainText(
"error occurred"
);
});
const Error = () => (
<p role="alert">
An error occurred during save.
</p>
);
it("initially hano text in the alert space", async () => {
render(<CustomerForm original={blankCustomer} />);
expect(element("[role=alert]")).not.toContainText(
"error occurred"
);
});
const [error, setError] = useState(false);
const handleSubmit = async (event) => {
...
if (result.ok) {
...
} else {
setError(true);
}
}
<form>
<Error hasError={error} />
...
</form>
const Error = ({ hasError }) => (
<p role="alert">
{hasError ? "An error occurred during save." : ""}
</p>
);
That’s it for our CustomerForm implementation. Time for a little cleanup of our tests.
A common practice is to use nested describe blocks to set up stub values as scenarios for a group of tests. We have just written four tests that deal with the scenario of the POST /customers endpoint returning an error. Two of these are good candidates for a nested describe context.
We can then pull up the stub value into a beforeEach block. Let’s start with the describe block. Follow these steps:
it("renders an alert space", ...)
it("initially has no text in the alert space", ...)
describe("when POST request returns an error", () => {
it("does not notify onSave if the POST request returns an error", ...)
it("renders error message when fetch call fails", ...)
});
describe("when POST request returns an error", () => {
it("does not notify onSave", ...)
it("renders error message ", ...)
});
describe("when POST request returns an error", () => {
beforeEach(() => {
fetchSpy.stubReturnValue(fetchResponseError());
});
...
})
You’ve now seen how to use nested describe blocks to describe specific test scenarios, and that covers all the basic stubbing techniques. In the next section, we’ll continue our cleanup by introducing Jest’s own spy and stub functions, which are slightly simpler than the ones we’ve built ourselves.
So far in this chapter, you’ve built your own hand-crafted spy function, with support for stubbing values and with its own matcher. The purpose of that has been to teach you how test doubles work and to show the essential set of spy and stub patterns that you’ll use in your component tests.
However, our spy function and the toBeCalledWith matcher are far from complete. Rather than investing any more time in our hand-crafted versions, it makes sense to switch to Jest’s own functions now. These work in essentially the same way as our spy function but have a couple of subtle differences.
This section starts with a rundown of Jest’s test double functionality. Then, we’ll migrate the CustomerForm test suite away from our hand-crafted spy function. Finally, we’ll do a little more cleanup by extracting more test helpers.
Here’s a rundown of Jest test double support:
There’s a lot more available with the Jest API, but these are the core features and should cover most of your test-driven use cases.
Let’s convert our CustomerForm tests away from our hand-crafted spy function. We’ll start with the fetchSpy variable.
We’ll use jest.spyOn for this. It essentially creates a spy with jest.fn() and then assigns it to the global.fetch variable. The jest.spyOn function keeps track of every object that has been spied on so that it can auto-restore them without our intervention, using the restoreMock configuration property.
It also has a feature that blocks us from spying on any property that isn’t already a function. That will affect us because Node.js doesn’t have a default implementation of global.fetch. We’ll see how to solve that issue in the next set of steps.
It’s worth pointing out a really great feature of jest.fn(). The returned spy object acts as both the function itself and the mock object. It does this by attaching a special mock property to the returned function. The upshot of this is that we no longer need a fetchSpy variable to store our spy object. We can just refer to global.fetch directly, as we’re about to see.
Follow these steps:
beforeEach(() => {
initializeReactContainer();
jest
.spyOn(global, "fetch")
.mockResolvedValue(fetchResponseOk({}));
});
fetchSpy.stubResolvedValue(...);
Go ahead and replace them with the following code:
global.fetch.mockResolvedValue(...);
expect(global.fetch).toBeCalledWith(
"/customers",
expect.objectContaining({
method: "POST",
})
);
const bodyOfLastFetchRequest = () => {
const allCalls = global.fetch.mock.calls;
const lastCall = allCalls[allCalls.length - 1];
return JSON.parse(lastCall[1].body);
};
"jest": {
...,
"restoreMocks": true
}
it("notifies onSave when form is submitted", async () => {
const customer = { id: 123 };
global.fetch.mockResolvedValue(
fetchResponseOk(customer)
);
const saveSpy = jest.fn();
render(
<CustomerForm
original={blankCustomer}
onSave={saveSpy}
/>
);
await clickAndWait(submitButton());
expect(saveSpy).toBeCalledWith(customer);
});
Cannot spy the fetch property because it is not a function; undefined given instead
global.fetch = () => Promise.resolve({});
"setupFilesAfterEnv": [
"./test/domMatchers.js",
"./test/globals.js"
],
expect(saveSpy).not.toBeCalledWith();
As mentioned earlier in the chapter, this expectation is not correct, and we only used it because our hand-rolled matcher didn’t fully support this use case. What we want is for the expectation to fail if onSave is called in any form. Now that we’re using Jest’s own matchers, we can solve this more elegantly. Replace this expectation with the following code:
expect(saveSpy).not.toBeCalled();
Your CustomerForm test suite is now fully migrated. We’ll end this chapter by extracting some more helpers.
CustomerForm is not the only component that will call fetch: one of the exercises is to update AppointmentForm to also submit appointments to the server. It makes sense to reuse the common code we’ve used by pulling it out into its own module. Proceed as follows:
export const bodyOfLastFetchRequest = () => {
const allCalls = global.fetch.mock.calls;
const lastCall = allCalls[allCalls.length - 1];
return JSON.parse(lastCall[1].body);
};
export const fetchResponseOk = (body) =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(body),
});
export const fetchResponseError = () =>
Promise.resolve({ ok: false });
import { bodyOfLastFetchRequest } from "./spyHelpers";
import {
fetchResponseOk,
fetchResponseError,
} from "./builders/fetch";
export const fetchResponseOk = (body) => ({
ok: true,
json: () => Promise.resolve(body),
});
export const fetchResponseError = () => ({
ok: false,
});
You’re now ready to reuse these functions in the AppointmentForm test suite.
We’ve just explored test doubles and how they are used to verify interactions with collaborating objects, such as component props (onSave) and browser API functions (global.fetch). We looked in detail at spies and stubs, the two main types of doubles you’ll use. You also saw how to use a side-by-side implementation as a technique to keep your test failures under control while you switch from one implementation to another.
Although this chapter covered the primary patterns you’ll use when dealing with test doubles, we have one major one still to cover, and that’s how to spy on and stub out React components. That’s what we’ll look at in the next chapter.
Try the following exercises:
For more information, refer to the following sources:
https://reacttdd.com/mocking-cheatsheet
https://martinfowler.com/articles/mocksArentStubs.html
https://github.github.io/fetch
In the previous chapter, you saw how test doubles can be used to verify network requests that occur upon user actions, such as clicking a submit button. We can also use them to verify side effects when our components mount, like when we're fetching data from the server that the component needs to function. In addition, test doubles can be used to verify the rendering of child components. Both use cases often occur together with container components, which are responsible for simply loading data and passing it to another component for display.
In this chapter, we’ll build a new component, AppointmentsDayViewLoader, that loads the day’s appointments from the server and passes them to the AppointmentsDayView component that we implemented in Chapter 2, Rendering Lists and Detail Views. By doing so, the user can view a list of appointments occurring today.
In this chapter, we will cover the following topics:
These are likely the most difficult tasks you’ll encounter while test-driving React components.
The code files for this chapter can be found here: https://github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter07
In this section, we’re going to use the jest.mock test helper to replace the child component with a dummy implementation. Then, we’ll write expectations that check whether we passed the right props to the child component and that it is correctly rendered on the screen.
But first, let’s take a detailed look at how mocked components work.
The component we’re going to build in this chapter has the following shape:
export const AppointmentsDayViewLoader = ({ today }) => {
const [appointments, setAppointments] = useState([]);
useEffect(() => {
// fetch data from the server
const result = await global.fetch(...);
// populate the appointments array:
setAppointments(await result.json());
}, [today]);
return (
<AppointmentsDayView appointments={appointments} />
);
};
Its purpose is to display all the current appointments for a given day. This information is then passed into the component as the today prop. The component’s job is to fetch data from the server and then pass it to the AppointmentsDayView component, which we built previously and already tested.
Think about the tests we may need. First, we’d want a test to prove that AppointmentsDayView loads with no appointments shown initially. Then, we’d want some tests that verify our global.fetch call is called successfully, and the returned data is passed into AppointmentsDayView.
How do we test that AppointmentsDayView is called with the right data? We could repeat some of the tests we have already written in the test suite for AppointmentsDayView – for example, by testing that a list of appointments is displayed, and that the relevant appointment data is shown.
However, we’d then be introducing repetition into our test suites. If we modify how AppointmentsDayView works, we’ll have two places to update tests.
An alternative is to mock the component with a spy object. For this, we can use the jest.mock function, in tandem with a spy. This is how it will look:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(() => (
<div id="AppointmentsDayView" />
)),
}));
The first argument to the function is the file path that is being mocked. It must match the path that’s passed to the import statement. This function is mocking the entire module:
import { MyComponent } from "some/file/path";
jest.mock("/some/file/path", ...);
describe("something that uses MyComponent", () => {
});
In the preceding code, Jest hoists this call to the top of the file and hooks into import logic so that when the import statement is run, your mock is returned instead.
Any time AppointmentsDayView is referenced in either the test suite or the component under test, you’ll get this mock value rather than the real component. Instead of rendering our day view, we’ll get a single div with an id value of AppointmentsDayView.
The second parameter is the module factory parameter. This is a factory method that is invoked when the mock is imported. It should return a set of named exports – in our case, this means a single component, AppointmentsDayView.
Because the mock definition is hoisted to the top of the file, you can’t reference any variables in this function: they won’t have been defined by the time your function is run. However, you can write JSX, as we have done here!
The complexity of component mock setup
This code is super cryptic, I know. Thankfully, you generally just need to write it once. I often find myself copy-pasting mocks when I need to introduce a new one into a test suite. I’ll look up a previous one I wrote in some other test suite and copy it across, changing the relevant details.
So, now comes the big question: why would you want to do this?
Firstly, using mocks can improve test organization by encouraging multiple test suites with independent surface areas. If both a parent component and its child component are non-trivial components, then having two separate test suites for those components can help reduce the complexity of your test suites.
The parent component’s test suite will contain just a handful of tests to prove that the child component was rendered and passed the expected prop value.
By mocking out the child component in the parent component’s test suite, you are effectively saying, “I want to ignore this child component right now, but I promise I’ll test its functionality elsewhere!”
A further reason is that you may already have tests for the child component. This is the scenario we find ourselves in: we already have tests for AppointmentsDayView, so unless we want to repeat ourselves, it makes sense to mock out the component wherever it’s used.
An extension of this reason is the use of library components. Because someone else built them, you have reason to trust that they’ve been tested and do the right thing. And since they’re library components, chances are they do something quite complex anyway, so rendering them within your tests may have unintended side effects.
Perhaps you have a library component that builds all sorts of elaborate HTML widgets and you don’t want your test code to know that. Instead, you can treat it as a black box. In that scenario, it’s preferable to verify the prop values that are passed to the component, again trusting that the component works as advertised.
Library components often have complex component APIs that allow the component to be configured in many ways. Mocking the component allows you to write contract tests that ensure you’re setting up props correctly. We’ll see this later in Chapter 11, Test-Driving React Router, when we mock out React Router’s Link component.
The final reason to mock components is if they have side effects on mount, such as performing network requests to pull in data. By mocking out the component, your test suite does not need to account for those side effects. We’ll do this in Chapter 8, Building an Application Component.
With all that said, let’s start building our new component.
We’ll start by building a test suite for the new component:
import React from "react";
import {
initializeReactContainer,
render,
element,
} from "./reactTestExtensions";
import {
AppointmentsDayViewLoader
} from "../src/AppointmentsDayViewLoader";
import {
AppointmentsDayView
} from "../src/AppointmentsDayView";
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(() => (
<div id="AppointmentsDayView" />
)),
}));
describe("AppointmentsDayViewLoader", () => {
beforeEach(() => {
initializeReactContainer();
});
it("renders an AppointmentsDayView", () => {
await render(<AppointmentsDayViewLoader />);
expect(
element("#AppointmentsDayView")
).not.toBeNull();
});
});
Use of the ID attribute
If you have experience with React Testing Library, you may have come across the use of data-testid for identifying components. If you want to use these mocking techniques with React Testing Library, then you can use data-testid instead of the id attribute, and then find your element using the queryByTestId function.
Although it’s generally recommended not to rely on data-testid for selecting elements within your test suites, that doesn’t apply to mock components. You need IDs to be able to tell them apart because you could end up with more than a few mocked components all rendered by the same parent. Giving an ID to each component is the simplest way to find them for these DOM presence tests. Remember that the mocks will never make it outside of your unit testing environment, so there’s no harm in using IDs.
For more discussions on mocking strategies with React Testing Library, head over to https://reacttdd.com/mocking-with-react-testing-library.
import React from "react";
import {
AppointmentsDayView
} from "./AppointmentsDayView";
export const AppointmentsDayViewLoader = () => (
<AppointmentsDayView />
);
AppointmentsDayView is what we expect. We’ll do this by using the toBeCalledWith matcher, which we’ve used already. Notice the second parameter value of expect.anything(): that’s needed because React passes a second parameter to the component function when it’s rendered. You’ll never need to be concerned with this in your code – it’s an internal detail of React’s implementation – so we can safely ignore it. We’ll use expect.anything to assert that we don’t care what that parameter is:
it("initially passes empty array of appointments to AppointmentsDayView", () => {
await render(<AppointmentsDayViewLoader />);
expect(AppointmentsDayView).toBeCalledWith(
{ appointments: [] },
expect.anything()
);
});
Verifying props and their presence in the DOM
It’s important to test both props that were passed to the mock and that the stubbed value is rendered in the DOM, as we have done in these two tests. In Chapter 8, Building an Application Component, we’ll see a case where we want to check that a mocked component is unmounted after a user action.
export const AppointmentsDayViewLoader = () => (
<AppointmentsDayView appointments={[]} />
);
You’ve just used your first mocked component! You’ve seen how to create the mock, and the two types of tests needed to verify its use. Next, we’ll add a useEffect hook to load data when the component is mounted and pass it through to the appointments prop.
The appointment data we’ll load comes from an endpoint that takes start and end dates. These values filter the result to a specific time range:
GET /appointments/<from>-<to>
Our new component is passed a today prop that is a Date object with the value of the current time. We will calculate the from and to dates from the today prop and construct a URL to pass to global.fetch.
To get there, first, we’ll cover a bit of theory on testing the useEffect hook. Then, we’ll implement a new renderAndWait function, which we’ll need because we’re invoking a promise when the component is mounted. Finally, we’ll use that function in our new tests, building out the complete useEffect implementation.
The useEffect hook is React’s way of running side effects. The idea is that you provide a function that will run each time any of the hook’s dependencies change. That dependency list is specified as the second parameter to the useEffect call.
Let’s take another look at our example:
export const AppointmentsDayViewLoader = ({ today }) => {
useEffect(() => {
// ... code runs here
}, [today]);
// ... render something
}
The hook code will run any time the today prop changes. This includes when the component first mounts. When we test-drive this, we’ll start with an empty dependency list and then use a specific test to force a refresh when the component is remounted with a new today prop value.
The function you pass to useEffect should return another function. This function performs teardown: it is called any time the value changes, especially before the hook function is invoked again, enabling you to cancel any running tasks.
We’ll explore this return function in detail in Chapter 15, Adding Animation. However, for now, you should be aware that this affects how we call promises. We can’t do this:
useEffect(async () => { ... }, []);
Defining the outer function as async would mean it returns a promise, not a function. We must do this instead:
useEffect(() => {
const fetchAppointments = async () => {
const result = await global.fetch(...);
setAppointments(await result.json());
};
fetchAppointments();
}, [today]);
When running tests, if you were to call global.fetch directly from within the useEffect hook, you’d receive a warning from React. It would alert you that the useEffect hook should not return a promise.
Using setters inside useEffect Hook functions
React guarantees that setters such as setAppointments remain static. This means they don’t need to appear in the useEffect dependency list.
To get started with our implementation, we’ll need to ensure our tests are ready for render calls that run promises.
Just as we did with clickAndWait and submitAndWait, now, we need renderAndWait. This will render the component and then wait for our useEffect hook to run, including any promise tasks.
To be clear, this function is necessary not because of the useEffect hook itself – just a normal sync act call would ensure that it runs – because of the promise that useEffect runs:
export const renderAndWait = (component) =>
act(async () => (
ReactDOM.createRoot(container).render(component)
)
);
import {
initializeReactContainer,
renderAndWait,
element,
} from "./reactTestExtensions";
it("renders an AppointmentsDayView", async () => {
await renderAndWait(<AppointmentsDayViewLoader />);
expect(
element("#AppointmentsDayView")
).not.toBeNull();
});
it("initially passes empty array of appointments to AppointmentsDayView", async () => {
await renderAndWait(<AppointmentsDayViewLoader />);
expect(AppointmentsDayView).toBeCalledWith(
{ appointments: [] },
expect.anything()
);
});
Make sure to check that these tests are passing before you continue.
We’re about to introduce a useEffect hook with a call to global.fetch. We’ll start by mocking that call using jest.spyOn. Then, we’ll continue with the test:
import { todayAt } from "./builders/time";
import { fetchResponseOk } from "./builders/fetch";
describe("AppointmentsDayViewLoader", () => {
const appointments = [
{ startsAt: todayAt(9) },
{ startsAt: todayAt(10) },
];
...
});
beforeEach(() => {
initializeReactContainer();
jest
.spyOn(global, "fetch")
.mockResolvedValue(fetchResponseOk(appointments));
});
it("fetches data when component is mounted", async () => {
const from = todayAt(0);
const to = todayAt(23, 59, 59, 999);
await renderAndWait(
<AppointmentsDayViewLoader today={today} />
);
expect(global.fetch).toBeCalledWith(
`/appointments/${from}-${to}`,
{
method: "GET",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
}
);
});
import React, { useEffect } from "react";
export const AppointmentsDayViewLoader = (
{ today }
) => {
useEffect(() => {
const from = today.setHours(0, 0, 0, 0);
const to = today.setHours(23, 59, 59, 999);
const fetchAppointments = async () => {
await global.fetch(
`/appointments/${from}-${to}`,
{
method: "GET",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
}
);
};
fetchAppointments();
}, []);
return <AppointmentsDayView appointments={[]} />;
};
AppointmentsDayViewLoader.defaultProps = {
today: new Date(),
};
it("passes fetched appointments to AppointmentsDayView once they have loaded", async () => {
await renderAndWait(<AppointmentsDayViewLoader />);
expect(
AppointmentsDayView
).toHaveBeenLastCalledWith(
{ appointments },
expect.anything()
);
});
import React, { useEffect, useState } from "react";
export const AppointmentsDayViewLoader = (
{ today }
) => {
const [
appointments, setAppointments
] = useState([]);
useEffect(() => {
...
const fetchAppointments = async () => {
const result = await global.fetch(
...
);
setAppointments(await result.json());
};
fetchAppointments();
}, []);
return (
<AppointmentsDayView
appointments={appointments}
/>
);
};
This completes the basic useEffect implementation – our component is now loading data. However, there’s a final piece we must address with the useEffect implementation.
The second parameter to the useEffect call is a dependency list that defines the variables that should cause the effect to be re-evaluated. In our case, the today prop is the important one. If the component is re-rendered with a new value for today, then we should pull down new appointments from the server.
We’ll write a test that renders a component twice. This kind of test is very important any time you’re using the useEffect hook. To support that, we’ll need to adjust our render functions to ensure they only create one root:
export let container;
let reactRoot;
export const initializeReactContainer = () => {
container = document.createElement("div");
document.body.replaceChildren(container);
reactRoot = ReactDOM.createRoot(container);
};
export const render = (component) =>
act(() => reactRoot.render(component));
export const renderAndWait = (component) =>
act(async () => reactRoot.render(component));
import {
today,
todayAt,
tomorrow,
tomorrowAt
} from "./builders/time";
it("re-requests appointment when today prop changes", async () => {
const from = tomorrowAt(0);
const to = tomorrowAt(23, 59, 59, 999);
await renderAndWait(
<AppointmentsDayViewLoader today={today} />
);
await renderAndWait(
<AppointmentsDayViewLoader today={tomorrow} />
);
expect(global.fetch).toHaveBeenLastCalledWith(
`/appointments/${from}-${to}`,
expect.anything()
);
});
AppointmentsDayViewLoader ' re-requests appointment when today prop changes
expect(
jest.fn()
).toHaveBeenLastCalledWith(...expected)
Expected: "/appointments/1643932800000-1644019199999", Anything
Received: "/appointments/1643846400000-1643932799999", {"credentials": "same-origin", "headers": {"Content-Type": "application/json"}, "method": "GET"}
useEffect(() => {
...
}, [today]);
That’s it for the implementation of this component. In the next section, we’ll clean up our test code with a new matcher.
In this section, we’ll introduce a new matcher, toBeRenderedWithProps, that simplifies the expectations for our mock spy object.
Recall that our expectations look like this:
expect(AppointmentsDayView).toBeCalledWith(
{ appointments },
expect.anything()
);
Imagine if you were working on a team that had tests like this. Would a new joiner understand what that second argument, expect.anything(), is doing? Will you understand what this is doing if you don’t go away for a while and forget how component mocks work?
Let’s wrap that into a matcher that allows us to hide the second property.
We need two matchers to cover the common use cases. The first, toBeRenderedWithProps, is the one we’ll work through in this chapter. The second, toBeFirstRenderedWithProps, is left as an exercise for you.
The matcher, toBeRenderedWithProps, will pass if the component is currently rendered with the given props. This function will be equivalent to using the toHaveBeenLastCalledWith matcher.
The essential part of this matcher is when it pulls out the last element of the mock.calls array:
const mockedCall = mockedComponent.mock.calls[ mockedComponent.mock.calls.length – 1 ];
The mock.calls array
Recall that every mock function that’s created with jest.spyOn or jest.fn will have a mock.calls property, which is an array of all the calls. This was covered in Chapter 6, Exploring Test Doubles.
The second matcher is toBeFirstRenderedWithProps. This will be useful for any test that checks the initial value of the child props and before any useEffect hooks have run. Rather than picking the last element of the mock.calls array, we’ll just pick the first:
const mockedCall = mockedComponent.mock.calls[0];
Let’s get started with toBeRenderedWithProps:
import React from "react";
import {
toBeRenderedWithProps,
} from "./toBeRenderedWithProps";
import {
initializeReactContainer,
render,
} from "../reactTestExtensions";
describe("toBeRenderedWithProps", () => {
let Component;
beforeEach(() => {
initializeReactContainer();
Component = jest.fn(() => <div />);
});
});
it("returns pass is true when mock has been rendered", () => {
render(<Component />);
const result = toBeRenderedWithProps(Component, {});
expect(result.pass).toBe(true);
});
export const toBeRenderedWithProps = (
mockedComponent,
expectedProps
) => ({ pass: true });
it("returns pass is false when the mock has not been rendered", () => {
const result = toBeRenderedWithProps(Component, {});
expect(result.pass).toBe(false);
});
export const toBeRenderedWithProps = (
mockedComponent,
expectedProps
) => ({
pass: mockedComponent.mock.calls.length > 0,
});
it("returns pass is false when the properties do not match", () => {
render(<Component a="b" />);
const result = toBeRenderedWithProps(
Component, {
c: "d",
}
);
expect(result.pass).toBe(false);
});
import { equals } from "@jest/expect-utils";
export const toBeRenderedWithProps = (
mockedComponent,
expectedProps
) => {
const mockedCall = mockedComponent.mock.calls[0];
const actualProps = mockedCall ?
mockedCall[0] : null;
const pass = equals(actualProps, expectedProps);
return { pass };
};
it("returns pass is true when the properties of the last render match", () => {
render(<Component a="b" />);
render(<Component c="d" />);
const result = toBeRenderedWithProps(
Component,
{ c: "d" }
);
expect(result.pass).toBe(true);
});
export const toBeRenderedWithProps = (
mockedComponent,
expectedProps
) => {
const mockedCall =
mockedComponent.mock.calls[
mockedComponent.mock.calls.length – 1
];
...
};
import {
toBeRenderedWithProps,
} from "./matchers/toBeRenderedWithProps";
expect.extend({
...,
toBeRenderedWithProps,
});
it("passes fetched appointments to AppointmentsDayView once they have loaded", async () => {
await renderAndWait(<AppointmentsDayViewLoader />);
expect(AppointmentsDayView).toBeRenderedWithProps({
appointments,
});
});
With that, you’ve learned how to build a matcher for component mocks, which reduces the verbiage that we originally had when we used the built-in toBeCalledWith matcher.
The other test in this test suite needs a second matcher, toBeFirstRenderedWithProps. The implementation of this is left as an exercise for you.
In the next section, we’ll look at a variety of ways that component mocks can become more complicated.
Before we finish up this chapter, let’s take a look at some variations on the jest.mock call that you may end up using.
The key thing to remember is to keep your mocks as simple as possible. If you start to feel like your mocks need to become more complex, you should treat that as a sign that your components are overloaded and should be broken apart in some way.
That being said, there are cases where you must use different forms of the basic component mock.
To begin with, you can simplify your jest.mock calls by not using jest.fn:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: () => (
<div id="AppointmentsDayView" />
),
}));
With this form, you’ve set a stub return value, but you won’t be able to spy on any props. This is sometimes useful if, for example, you’ve got multiple files that are testing this same component but only some of them verify the mocked component props. It can also be useful with third-party components.
Sometimes, you’ll want to render grandchild components, skipping out the child (their parent). This often happens, for example, when a third-party component renders a complex UI that is difficult to test: perhaps it loads elements via the shadow DOM, for example. In that case, you can pass children through your mock:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(({ children }) => (
<div id="AppointmentsDayView">{children}</div>
)),
}));
We will see examples of this in Chapter 11, Test-Driving React Router.
There are occasions when you’ll want to mock a component that has been rendered multiple times into the document. How can you tell them apart? If they have a unique ID prop (such as key), you can use that in the id field:
jest.mock("../src/AppointmentsDayView", () => ({
AppointmentsDayView: jest.fn(({ key }) => (
<div id={`AppointmentsDayView${key}`} />
)),
}));
Approach with caution!
One of the biggest issues with mocking components is that mock definitions can get out of control. But mock setup is complicated and can be very confusing. Because of this, you should avoid writing anything but the simplest mocks.
Thankfully, most of the time, the plain form of component mock is all you’ll need. These variants are useful occasionally but should be avoided.
We’ll see this variation in action in Chapter 11, Test-Driving React Router.
Mocking out an entire module is fairly heavy-handed. The mock you set up must be used for all the tests in the same test module: you can’t mix and match tests, some using the mock and some not. If you wanted to do this with jest.mock, you’d have to create two test suites. One would have the mock and the other wouldn’t.
You also have the issue that the mock is at the module level. You can’t just mock out one part of the module. Jest has functions that allow you to reference the original implementation called requireActual. For me, that involves moving into the danger zone of overly complex test doubles, so I refrain from using it – I have encountered a use case that needed it.
However, there are alternatives to using jest.mock. One is shallow rendering, which utilizes a special renderer that renders a single parent component, ignoring all child components other than standard HTML elements. In a way, this is even more heavy-handed because all your components end up mocked out.
For CommonJS modules, you can also overwrite specific exports inside modules, simply by assigning new values to them! This gives you a much more granular way of setting mocks at the test level. However, this is not supported in ECMAScript, so in the interests of maximum capability, you may want to avoid this approach.
For examples of these alternative approaches and a discussion on when you may want to use them, take a look at https://reacttdd.com/alternatives-to-module-mocks.
This chapter covered the most complex form of mocking: setting up component mocks with jest.mock.
Since mocking is a complex art form, it’s best to stick with a small set of established patterns, which I’ve shown in this chapter. You can also refer to the code in Chapter 11, Test-Driving React Router, for examples that show some of the variations that have been described in this chapter.
You also learned how to test-drive a useEffect hook before writing another matcher.
You should now feel confident with testing child components by using component mocks, Including loading data into those components through useEffect actions.
In the next chapter, we’ll extend this technique further by pulling out callback props from mock components and invoking them within our tests.
The following are some exercises for you to try out:
To learn how to mock components without relying on jest.mock, please check out https://reacttdd.com/alternatives-to-module-mocks.
The components you’ve built so far have been built in isolation: they don’t fit together, and there’s no workflow for the user to follow when they load the application. Up to this point, we’ve been manually testing our components by swapping them in and out of our index file, src/index.js.
In this chapter, we’ll tie all those components into a functioning system by creating a root application component, App, that displays each of these components in turn.
You have now seen almost all the TDD techniques you’ll need for test-driving React applications. This chapter covers one final technique: testing callback props.
In this chapter, we will cover the following topics:
By the end of this chapter, you’ll have learned how to use mocks to test the root component of your application, and you’ll have a working application that ties together all the components you’ve worked on in Part 1 of this book.
The code files for this chapter can be found here: https://github.com/PacktPublishing/Mastering-React-Test-Driven-Development-Second-Edition/tree/main/Chapter08
Before we jump into the code for the App component, let’s do a little up-front design so that we know what we’re building.
The following diagram shows all the components you’ve built and how App will connect them:
Figure 8.1 – The component hierarchy
Here’s how it’ll work:
This first step is shown in the following screenshot. Here, you can see the new button in the top-left corner. The App component will render this button and then orchestrate this workflow:
Figure 8.2 – The app showing the new button in the top-left corner
This is a very simple workflow that supports just a single use case: adding a new customer and an appointment at the same time. Later in this book, we’ll add support for creating appointments for existing customers.
With that, we’re ready to build the new App component.
In this section, we’ll start building a new App component, in the usual way. First, we’ll display an AppointmentsDayViewLoader component. Because this child component makes a network request when mounted, we’ll mock it out. Then, we’ll add a button inside a menu element, at the top of the page. When this button is clicked, we switch out the AppointmentsDayViewLoader component for a CustomerForm component.
We will introduce a state variable named view that defines which component is currently displayed. Initially, it will be set to dayView. When the button is clicked, it will change to addCustomer.
The JSX constructs will initially use a ternary to switch between these two views. Later, we’ll add a third value called addAppointment. When we do that, we’ll “upgrade” our ternary expression to a switch statement.
To get started, follow these steps:
import React from "react";
import {
initializeReactContainer,
render,
} from "./reactTestExtensions";
import { App } from "../src/App";
import {
AppointmentsDayViewLoader
} from "../src/AppointmentsDayViewLoader";
jest.mock("../src/AppointmentsDayViewLoader", () => ({
AppointmentsDayViewLoader: jest.fn(() => (
<div id="AppointmentsDayViewLoader" />
)),
}));
describe("App", () => {
beforeEach(() => {
initializeReactContainer();
});
it("initially shows the AppointmentDayViewLoader", () => {
render(<App />);
expect(AppointmentsDayViewLoader).toBeRendered();
});
});
import React from "react";
import ReactDOM from "react-dom";
import {
AppointmentsDayViewLoader
} from "./AppointmentsDayViewLoader";
export const App = () => (
<AppointmentsDayViewLoader />
);
import {
initializeReactContainer,
render,
element,
} from "./reactTestExtensions";
it("has a menu bar", () => {
render(<App />);
expect(element("menu")).not.toBeNull();
});
export const App = () => (
<>
<menu />
<AppointmentsDayViewLoader />
</>
)
it("has a button to initiate add customer and appointment action", () => {
render(<App />);
const firstButton = element(
"menu > li > button:first-of-type"
);
expect(firstButton).toContainText(
"Add customer and appointment"
);
});
<menu>
<li>
<button type="button">
Add customer and appointment
</button>
<li>
</menu>
import { CustomerForm } from "../src/CustomerForm";
jest.mock("../src/CustomerForm", () => ({
CustomerForm: jest.fn(() => (
<div id="CustomerForm" />
)),
}));
Why mock a component that has no effects on mount?
This component already has a test suite so that we can use a test double and verify the right props to avoid re-testing functionality we’ve tested elsewhere. For example, the CustomerForm test suite has a test to check that the submit button calls the onSave prop with the saved customer object. So, rather than extending the test surface area of App so that it includes that submit functionality, we can mock out the component and call onSave directly instead. We’ll do that in the next section.
import {
initializeReactContainer,
render,
element,
click,
} from "./reactTestExtensions";
const beginAddingCustomerAndAppointment = () =>
click(element("menu > li > button:first-of-type"));
it("displays the CustomerForm when button is clicked", async () => {
render(<App />);
beginAddingCustomerAndAppointment();
expect(element("#CustomerForm")).not.toBeNull();
});
import React, { useState, useCallback } from "react";
import { CustomerForm } from "./CustomerForm";
const [view, setView] = useState("dayView");
const transitionToAddCustomer = useCallback(
() => setView("addCustomer"),
[]
);
<button
type="button"
onClick={transitionToAddCustomer}>
Add customer and appointment
</button>
return (
<>
<menu>
...
</menu>
{view === "addCustomer" ? <CustomerForm /> : null}
</>
);
Testing for the presence of a new component
Strictly speaking, this isn’t the simplest way to make the test pass. We could make it pass by always rendering a CustomerForm component, regardless of the value of view. Then, we’d need to triangulate with a second test that proves the component is not initially rendered. I’m skipping this step for brevity, but feel free to add it in if you prefer.
it("passes a blank original customer object to CustomerForm", async () => {
render(<App />);
beginAddingCustomerAndAppointment();
expect(CustomerForm).toBeRenderedWithProps(
expect.objectContaining({
original: blankCustomer
})
);
});
export const blankCustomer = {
firstName: "",
lastName: "",
phoneNumber: "",
};
import { blankCustomer } from "./builders/customer";
Value builders versus function builders
We’ve defined blankCustomer as a constant value, rather than a function. We can do this because all the code we’ve written treats variables as immutable objects. If that wasn’t the case, we may prefer to use a function, blankCustomer(), that generates new values each time it is called. That way, we can be sure that one test doesn’t accidentally modify the setup for any subsequent tests.
const blankCustomer = {
firstName: "",
lastName: "",
phoneNumber: "",
};
Using builder functions in both production and test code
You now have the same blankCustomer definition in both your production and test code. This kind of duplication is usually okay, especially since the object is so simple. But for non-trivial builder functions, you should consider test-driving the implementation and then making good use of it within your test suite.
{view === "addCustomer" ? (
<CustomerForm original={blankCustomer} />
) : null}
it("hides the AppointmentsDayViewLoader when button is clicked", async () => {
render(<App />);
beginAddingCustomerAndAppointment();
expect(
element("#AppointmentsDayViewLoader")
).toBeNull();
});
{ view === "addCustomer" ? (
<CustomerForm original={blankCustomer} />
) : (
<AppointmentsDayViewLoader />
)}
it("hides the button bar when CustomerForm is being displayed", async () => {
render(<App />);
beginAddingCustomerAndAppointment();
expect(element("menu")).toBeNull();
});
return view === "addCustomer" ? (
<CustomerForm original={blankCustomer} />
) : (
<>
<menu>
...
</menu>
<AppointmentsDayViewLoader />
</>
);
With that, you have implemented the initial step in the workflow – that is changing the screen from an AppointmentsDayViewLoader component to a CustomerForm component. You did this by changing the view state variable from dayView to addCustomer. For the next step, we’ll use the onSave prop of CustomerForm to alert us when it’s time to update view to addAppointment.
In this section, we’ll introduce a new extension function, propsOf, that reaches into a mocked child component and returns the props that were passed to it. We’ll use this to get hold of the onSave callback prop value and invoke it from our test, mimicking what would happen if the real CustomerForm had been submitted.
It’s worth revisiting why this is something we’d like to do. Reaching into a component and calling the prop directly seems complicated. However, the alternative is more complicated and more brittle.
The test we want to write next is the one that asserts that the AppointmentFormLoader component is shown after CustomerForm has been submitted and a new customer has been saved:
it("displays the AppointmentFormLoader after the CustomerForm is submitted", async () => {
// ...
});
Now, imagine that we wanted to test this without a mocked CustomerForm. We would need to fill in the real CustomerForm form fields and hit the submit button. That may seem reasonable, but we’d be increasing the surface area of our App test suite to include the CustomerForm component. Any changes to the CustomerForm component would require not only the CustomerForm tests to be updated but also now the App tests. This is the exact scenario we’ll see in Chapter 9, Form Validation, when we update CustomerForm so that it includes field validation.
By mocking the child component, we can reduce the surface area and reduce the likelihood of breaking tests when child components change.
Mocked components require care
Even with mocked components, our parent component test suite can still be affected by child component changes. This can happen if the meaning of the props changes. For example, if we updated the onSave prop on CustomerForm to return a different value, we’d need to update the App tests to reflect that.
Here’s what we’ve got to do. First, we must define a propsOf function in our extensions module. Then, we must write tests that mimic the submission of a CustomerForm component and transfer the user to an AppointmentFormLoader component. We’ll do that by introducing a new addAppointment value for the view state variable. Follow these steps:
export const propsOf = (mockComponent) => {
const lastCall = mockComponent.mock.calls[
mockComponent.mock.calls.length – 1
];
return lastCall[0];
};
import {
initializeReactContainer,
render,
element,
click,
propsOf,
} from "./reactTestExtensions";
import { act } from "react-dom/test-utils";
import {
AppointmentFormLoader
} from "../src/AppointmentFormLoader";
jest.mock("../src/AppointmentFormLoader", () => ({
AppointmentFormLoader: jest.fn(() => (
<div id="AppointmentFormLoader" />
)),
}));
const exampleCustomer = { id: 123 };
const saveCustomer = (customer = exampleCustomer) =>
act(() => propsOf(CustomerForm).onSave(customer));
Using act within the test suite
This is the first occasion that we’ve willingly left a reference to act within our test suite. In every other use case, we managed to hide calls to act within our extensions module. Unfortunately, that’s just not possible here – at least, it’s not possible with the way we wrote propsOf. An alternative approach would be to write an extension function named invokeProp that takes the name of a prop and invokes it for us:
invokeProp(CustomerForm, "onSave", customer);
The downside of this approach is that you’ve now downgraded onSave from an object property to a string. So, we’ll ignore this approach for now and just live with act usage in our test suite.
it("displays the AppointmentFormLoader after the CustomerForm is submitted", async () => {
render(<App />);
beginAddingCustomerAndAppointment();
saveCustomer();
expect(
element("#AppointmentFormLoader")
).not.toBeNull();
});
switch (view) {
case "addCustomer":
return (
<CustomerForm original={blankCustomer} />
);
default:
return (
<>
<menu>
<li>
<button
type="button"
onClick={transitionToAddCustomer}>
Add customer and appointment
</button>
</li>
</menu>
<AppointmentsDayViewLoader />
</>
);
}
const transitionToAddAppointment = useCallback(
() => {
setView("addAppointment")
}, []);
<CustomerForm
original={blankCustomer}
onSave={transitionToAddAppointment}
/>
case "addAppointment":
return (
<AppointmentFormLoader />
);
it("passes a blank original appointment object to CustomerForm", async () => {
render(<App />);
beginAddingCustomerAndAppointment();
saveCustomer();
expect(AppointmentFormLoader).toBeRenderedWithProps(
expect.objectContaining({
original:
expect.objectContaining(blankAppointment),
})
);
});
export const blankAppointment = {
service: "",
stylist: "",
startsAt: null,
};
import {
blankAppointment
} from "./builders/appointment";
const blankAppointment = {
service: "",
stylist: "",
startsAt: null,
};
<AppointmentFormLoader original={blankAppointment} />
We’re almost done with the display of AppointmentFormLoader, but not quite: we still need to take the customer ID we receive from the onSave callback and pass it into AppointmentFormLoader, by way of the original prop value, so that AppointmentForm knows which customer we’re creating an appointment for.
In this section, we’ll introduce a new state variable, customer, that will be set when CustomerForm receives the onSave callback. After that, we’ll do the final transition in our workflow, from addAppointment back to dayView.
Follow these steps:
it("passes the customer to the AppointmentForm", async () => {
const customer = { id: 123 };
render(<App />);
beginAddingCustomerAndAppointment();
saveCustomer(customer);
expect(AppointmentFormLoader).toBeRenderedWithProps(
expect.objectContaining({
original: expect.objectContaining({
customer: customer.id,
}),
})
);
});
const [customer, setCustomer] = useState();
const transitionToAddAppointment = useCallback(
(customer) => {
setCustomer(customer);
setView("addAppointment")
}, []);
case "addAppointment":
return (
<AppointmentFormLoader
original={{
...blankAppointment,
customer: customer.id,
}}
/>
);
const saveAppointment = () =>
act(() => propsOf(AppointmentFormLoader).onSave());
it("renders AppointmentDayViewLoader after AppointmentForm is submitted", async () => {
render(<App />);
beginAddingCustomerAndAppointment();
saveCustomer();
saveAppointment();
expect(AppointmentsDayViewLoader).toBeRendered();
});
const transitionToDayView = useCallback(
() => setView("dayView"),
[]
);
case "addAppointment":
return (
<AppointmentFormLoader
original={{
...blankAppointment,
customer: customer.id,
}}
onSave={transitionToDayView}
/>
);
We’re done!
Now, all that’s left is to update src/index.js to render the App component. Then, you can manually test this to check out your handiwork:
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
ReactDOM
.createRoot(document.getElementById("root"))
.render(<App />);
To run the application, use the npm run serve command. For more information see the Technical requirements section in Chapter 6, Exploring Test Doubles, or consult the README.md file in the repository.
This chapter covered the final TDD technique for you to learn – mocked component callback props. You learned how to get a reference to a component callback using the propsOf extension, as well as how to use a state variable to manage the transitions between different parts of a workflow.
You will have noticed how all the child components in App were mocked out. This is often the case with top-level components, where each child component is a relatively complex, self-contained unit.
In the next part of this book, we’ll apply everything we’ve learned to more complex scenarios. We’ll start by introducing field validation into our CustomerForm component.
The following are some exercises for you to try out:
This part builds on the basic techniques you’ve learned in Part 1 by applying them to real-world problems that you’ll encounter in your work, and introduces libraries that many React developers use: React Router, Redux, and Relay (GraphQL). The goal is to show you how the TDD workflow can be used even for large applications.
This part includes the following chapters:
For many programmers, TDD makes sense when it involves toy programs that they learn in a training environment. But they find it hard to join the dots when they are faced with the complexity of real-world programs. The purpose of this part of this book is for you to apply the techniques you’ve learned to real-world applications.
This chapter takes a somewhat self-indulgent journey into form validation. Normally, with React, you’d reach for a ready-made form library that handles validation for you. But in this chapter, we’ll hand-craft our own validation logic, as an example of how real-world complexity can be conquered with TDD.
You will uncover an important architectural principle when dealing with frameworks such as React: take every opportunity to move logic out of framework-controlled components and into plain JavaScript objects.
In this chapter, we will cover the following topics:
By the end of the chapter, you’ll have seen how tests can be used to introduce validation into your React forms.
The code files for this chapter can be found here:
In this section, we’ll update the CustomerForm and AppointmentForm components so that they alert the user to any issues with the text they’ve entered. For example, if they enter non-digit characters into the phone number field, the application will display an error.
We’ll listen for the DOM’s blur event on each field to take the current field value and run our validation rules on it.
Any validation errors will be stored as strings, such as First name is required, within a validationErrors state variable. Each field has a key in this object. An undefined value (or absence of a value) represents no validation error, and a string value represents an error. Here’s an example:
{
firstName: "First name is required",
lastName: undefined,
phoneNumber: "Phone number must contain only numbers, spaces, and any of the following: + - ( ) ."
}
This error is rendered in the browser like this:
Figure 9.1 – Validation errors displayed to the user
To support tests that manipulate the keyboard focus, we need a new function that simulates the focus and blur events being raised when the user completes a field value. We’ll call this function withFocus. It wraps a test-supplied action (such as changing the field value) with the focus/blur events.
This section will start by checking that the CustomerForm first name field is supplied. Then, we’ll generalize that validation so that it works for all three fields in the form. After that, we’ll ensure validation also runs when the submit button is pressed. Finally, we’ll extract all the logic we’ve built into a separate module.
Each of the three fields on our page – firstName, lastName, and phoneNumber – are required fields. If a value hasn’t been provided for any of the fields, the user should see a message telling them that. To do that, each of the fields will have an alert message area, implemented as a span with an ARIA role of alert.
Let’s begin by adding that alert for the firstName field, and then making it operational by validating the field when the user removes focus:
describe("validation", () => {
it("renders an alert space for first name validation errors", () => {
render(<CustomerForm original={blankCustomer} />);
expect(
element("#firstNameError[role=alert]")
).not.toBeNull();
});
});
<input
type="text"
name="firstName"
id="firstName"
value={customer.firstName}
onChange={handleChange}
/>
<span id="firstNameError" role="alert" />
it("sets alert as the accessible description for the first name field", async () => {
render(<CustomerForm original={blankCustomer} />);
expect(
field(
"firstName"
).getAttribute("aria-describedby")
).toEqual("firstNameError");
});
<input
type="text"
name="firstName"
id="firstName"
value={customer.firstName}
onChange={handleChange}
aria-describedby="firstNameError"
/>
export const withFocus = (target, fn) =>
act(() => {
target.focus();
fn();
target.blur();
});
The focus and blur sequence
The initial call to focus is needed because if the element isn’t focused, JSDOM will think that blur has nothing to do.
import {
...,
withFocus,
} from "./reactTestExtensions";
it("displays error after blur when first name field is blank", () => {
render(<CustomerForm original={blankCustomer} />);
withFocus(field("firstName"), () =>
change(field("firstName"), " ");
)
expect(
element("#firstNameError[role=alert]")
).toContainText("First name is required");
});
<span id="firstNameError" role="alert">
First name is required
</span>
it("initially has no text in the first name field alert space", async () => {
render(<CustomerForm original={blankCustomer} />);
expect(
element("#firstNameError[role=alert]").textContent
).toEqual("");
});
A matcher for empty text content
Although not covered in this book, this would be a good opportunity to build a new matcher such as toHaveNoText, or maybe not.toContainAnyText.
const required = value =>
!value || value.trim() === ""
? "First name is required"
: undefined;
const [
validationErrors, setValidationErrors
] = useState({});
const handleBlur = ({ target }) => {
const result = required(target.value);
setValidationErrors({
...validationErrors,
firstName: result
});
};
const hasFirstNameError = () =>
validationErrors.firstName !== undefined;
<input
type="text"
name="firstName"
...
onBlur={handleBlur}
/>
<span id="firstNameError" role="alert">
{hasFirstNameError()
? validationErrors["firstName"]
: ""}
</span>
You now have a completed, working system for validating the first name field.
Next, we’ll add the required validation to the last name and phone number fields.
Since we’re on green, we can refactor our existing code before we write the next test. We will update the JSX and the hasFirstNameError and handleBlur functions so that they work for all the fields on the form.
This will be an exercise in systematic refactoring: breaking the refactoring down into small steps. After each step, we’re aiming for our tests to still be green:
const renderFirstNameError = () => (
<span id="firstNameError" role="alert">
{hasFirstNameError()
? validationErrors["firstName"]
: ""}
<span>
);
<input
type="text"
name="firstName"
...
/>
{renderFirstNameError()}
<input
type="text"
name="firstName"
...
/>
{renderFirstNameError("firstName")}
Always having green tests – JavaScript versus TypeScript
This section is written in a way that your tests should still be passing at every step. In the preceding step, we passed a parameter to renderFirstNameError that the function can’t accept yet. In JavaScript, this is perfectly fine. In TypeScript, you’ll get a type error when attempting to build your source.
const renderFirstNameError = (fieldName) => (
<span id={`${fieldName}Error`} role="alert">
{hasFirstNameError()
? validationErrors[fieldName]
: ""}
<span>
);
const renderFirstNameError = (fieldName) => (
<span id={`${fieldName}Error`} role="alert">
{hasFirstNameError(fieldName)
? validationErrors[fieldName]
: ""}
<span>
);
const hasFirstNameError = fieldName =>
validationErrors[fieldName] !== undefined;
hasFirstNameError so that it becomes hasError.
Refactoring support in your IDE
Your IDE may have renaming support built in. If it does, you should use it. Automated refactoring tools lessen the risk of human error.
const handleBlur = ({ target }) => {
const validators = {
firstName: required
};
const result =
validators[target.name](target.value);
setValidationErrors({
...validationErrors,
[target.name]: result
});
};
As you can see, the first half of the function (the definition of validators) is now static data that defines how the validation should happen for firstName. This object will be extended later, with the lastName and phoneNumber fields. The second half is generic and will work for any input field that’s passed in, so long as a validator exists for that field.
const required = description => value =>
!value || value.trim() === ""
? description
: undefined;
const validators = {
firstName: required("First name is required")
};
At this point, your tests should be passing and you should have a fully generalized solution. Now, let’s generalize the tests too, by converting our four validation tests into test generator functions:
const errorFor = (fieldName) =>
element(`#${fieldName}Error[role=alert]`);
const itRendersAlertForFieldValidation = (fieldName) => {
it(`renders an alert space for ${fieldName} validation errors`, async () => {
render(<CustomerForm original={blankCustomer} />);
expect(errorFor(fieldName)).not.toBeNull();
});
};
itRendersAlertForFieldValidation("firstName");
const itSetsAlertAsAccessibleDescriptionForField = (
fieldName
) => {
it(`sets alert as the accessible description for the ${fieldName} field`, async () => {
render(<CustomerForm original={blankCustomer} />);
expect(
field(fieldName).getAttribute(
"aria-describedby"
)
).toEqual(`${fieldName}Error`);
});
};
itSetsAlertAsAccessibleDescriptionForField(
"firstName"
);
const itInvalidatesFieldWithValue = (
fieldName,
value,
description
) => {
it(`displays error after blur when ${fieldName} field is '${value}'`, () => {
render(<CustomerForm original={blankCustomer} />);
withFocus(field(fieldName), () =>
change(field(fieldName), value)
);
expect(
errorFor(fieldName)
).toContainText(description);
});
};
itInvalidatesFieldWithValue(
"firstName",
" ",
"First name is required"
);
const itInitiallyHasNoTextInTheAlertSpace = (fieldName) => {
it(`initially has no text in the ${fieldName} field alert space`, async () => {
render(<CustomerForm original={blankCustomer} />);
expect(
errorFor(fieldName).textContent
).toEqual("");
});
};
itInitiallyHasNoTextInTheAlertSpace("firstName");
itRendersAlertForFieldValidation("lastName");
<label htmlFor="lastName">Last name</label>
<input
type="text"
name="lastName"
id="lastName"
value={customer.lastName}
onChange={handleChange}
/>
{renderError("lastName")}
itSetsAlertAsAccessibleDescriptionForField(
"lastName"
);
<input
type="text"
name="lastName"
...
aria-describedby="lastNameError"
/>
itInvalidatesFieldWithValue(
"lastName",
" ",
"Last name is required"
);
const validators = {
firstName: required("First name is required"),
lastName: required("Last name is required"),
};
itInitiallyHasNoTextInTheAlertSpace("lastName");
Who needs test generator functions?
Test generator functions can look complex. You may prefer to keep duplication in your tests or find some other way to extract common functionality from your tests.
There is a downside to the test generator approach: you won’t be able to use it.only or it.skip on individual tests.
With that, we’ve covered the required field validation. Now, let’s add a different type of validation for the phoneNumber field. We want to ensure the phone number only contains numbers and a few special characters: brackets, dashes, spaces, and pluses.
To do that, we’ll introduce a match validator that can perform the phone number matching we need, and a list validator that composes validations.
Let’s add that second validation:
itInvalidatesFieldWithValue(
"phoneNumber",
"invalid",
"Only numbers, spaces and these symbols are allowed: ( ) + -"
);
const match = (re, description) => value =>
!value.match(re) ? description : undefined;
Learning regular expressions
Regular expressions are a flexible mechanism for matching string formats. If you’re interested in learning more about them, and how to test-drive them, take a look at https://reacttdd.com/testing-regular-expressions.
const list = (...validators) => value =>
validators.reduce(
(result, validator) => result || validator(value),
undefined
);
const validators = {
...
phoneNumber: list(
required("Phone number is required"),
match(
/^[0-9+()\- ]*$/,
"Only numbers, spaces and these symbols are allowed: ( ) + -"
)
)
};
it("accepts standard phone number characters when validating", () => {
render(<CustomerForm original={blankCustomer} />);
withFocus(field("phoneNumber"), () =>
change(field("phoneNumber"), "0123456789+()- ")
);
expect(errorFor("phoneNumber")).not.toContainText(
"Only numbers"
);
});
Is this a valid test?
This test passes without any required changes. That breaks our rule of only writing tests that fail.
We got into this situation because we did too much in our previous test: all we needed to do was prove that the invalid string wasn’t a valid phone number. But instead, we jumped ahead and implemented the full regular expression.
If we had triangulated “properly,” with a dummy regular expression to start, we would have ended up in the same place we are now, except we’d have done a bunch of extra intermediate work that ends up being deleted.
In some scenarios, such as when dealing with regular expressions, I find it’s okay to short-circuit the process as it saves me some work.
With that, you’ve learned how to generalize validation using TDD.
What should happen when we submit the form? For our application, if the user clicks the submit button before the form is complete, the submission process should be canceled and all the fields should display their validation errors at once.
We can do this with two tests: one to check that the form isn’t submitted while there are errors, and another to check that all the fields are showing errors.
Before we do that, we’ll need to update our existing tests that submit the form, as they all assume that the form has been filled in correctly. First, we need to ensure that we pass valid customer data that can be overridden in each test.
Let’s get to work on the CustomerForm test suite:
export const validCustomer = {
firstName: "first",
lastName: "last",
phoneNumber: "123456789"
};
import {
blankCustomer,
validCustomer,
} from "./builders/customer";
render(<CustomerForm original={validCustomer} />);
it("does not submit the form when there are validation errors", async () => {
render(<CustomerForm original={blankCustomer} />);
await clickAndWait(submitButton());
expect(global.fetch).not.toBeCalled();
});
const validateMany = fields =>
Object.entries(fields).reduce(
(result, [name, value]) => ({
...result,
[name]: validators[name](value)
}),
{}
);
const anyErrors = errors =>
Object.values(errors).some(error => (
error !== undefined
)
);
const handleSubmit = async e {
e.preventDefault();
const validationResult = validateMany(customer);
if (!anyErrors(validationResult)) {
... existing code ...
}
}
import {
...,
textOf,
elements,
} from "./reactTestExtensions";
it("renders validation errors after submission fails", async () => {
render(<CustomerForm original={blankCustomer} />);
await clickAndWait(submitButton());
expect(
textOf(elements("[role=alert]"))
).not.toEqual("");
});
Using the alert role on multiple elements
This chapter uses multiple alert spaces, one for each form field. However, screen readers do not behave well when multiple alert roles show alerts at the same time – for example, if clicking the submit button causes a validation error to appear on all three of our fields.
An alternative approach would be to rework the UI so that it has an additional element that takes on the alert role when any errors are detected; after that, it should remove the alert role from the individual field error descriptions.
if (!anyErrors(validationResult)) {
} else {
setValidationErrors(validationResult);
}
You’ve now seen how to run all field validations when the form is submitted.
One useful design guideline is to get out of “framework land” as soon as possible. You want to be dealing with plain JavaScript objects. This is especially true for React components: extract as much logic as possible out into standalone modules.
There are a few different reasons for this. First, testing components is harder than testing plain objects. Second, the React framework changes more often than the JavaScript language itself. Keeping our code bases up to date with the latest React trends is a large-scale task if our code base is, first and foremost, a React code base. If we keep React at bay, our lives will be simpler in the longer term. So, we always prefer to write plain JavaScript when it’s an option.
Our validation code is a great example of this. We have several functions that do not care about React at all:
Let’s pull all of these out into a separate namespace called formValidation:
import {
required,
match,
list,
} from "./formValidation";
const renderError = fieldName => {
if (hasError(validationErrors, fieldName)) {
...
}
}
const hasError = (validationErrors, fieldName) =>
validationErrors[fieldName] !== undefined;
const validateMany = (validators, fields) =>
Object.entries(fields).reduce(
(result, [name, value]) => ({
...result,
[name]: validators[name](value)
}),
{}
);
const handleBlur = ({ target }) => {
const result = validateMany(validators, {
[target.name] : target.value
});
setValidationErrors({
...validationErrors,
...result
});
}
const validationResult = validateMany(
validators,
customer
);
import {
required,
match,
list,
hasError,
validateMany,
anyErrors,
} from "./formValidation";
Although this is enough to extract the code out of React-land, we’ve only just made a start. There is plenty of room for improvement with this API. There are a couple of different approaches that you could take here. The exercises for this chapter contain some suggestions on how to do that.
Using test doubles for validation functions
You may be thinking, do these functions now need their own unit tests? And should I update the tests in CustomerForm so that test doubles are used in place of these functions?
In this case, I would probably write a few tests for formValidation, just to make it clear how each of the functions should be used. This isn’t test-driving since you already have the code, but you can still mimic the experience by writing tests as you normally would.
When extracting functionality from components like this, it often makes sense to update the original components to simplify and perhaps move across tests. In this instance, I wouldn’t bother. The tests are high-level enough that they make sense, regardless of how the code is organized internally.
This section covered how to write validation logic for forms. You should now have a good awareness of how TDD can be used to implement complex requirements such as field validations. Next, we’ll integrate server-side errors into the same flow.
The /customers endpoint may return a 422 Unprocessable Entity error if the customer data failed the validation process. This could happen if, for example, the phone number already exists within the system. If this happens, we want to withhold calling the onSave callback and instead display the errors to the user and give them the chance to correct them.
The body of the response will contain error data very similar to the data we’ve built for the validation framework. Here’s an example of the JSON that would be received:
{
"errors": {
"phoneNumber": "Phone number already exists in the system"
}
}
We’ll update our code to display these errors in the same way our client errors appeared. Since we already handle errors for CustomerForm, we’ll need to adjust our tests in addition to the existing CustomerForm code.
Our code to date has made use of the ok property that’s returned from global.fetch. This property returns true if the HTTP status code is 200, and false otherwise. Now, we need to be more specific. For a status code of 422, we want to display new errors, and for anything else (such as a 500 error), we want to fall back to the existing behavior.
Let’s add support for those additional status codes:
const fetchResponseError = (
status = 500,
body = {}
) => ({
ok: false,
status,
json: () => Promise.resolve(body),
});
it("renders field validation errors from server", async () => {
const errors = {
phoneNumber: "Phone number already exists in the system"
};
global.fetch.mockResolvedValue(
fetchResponseError(422, { errors })
);
render(<CustomerForm original={validCustomer} />);
await clickAndWait(submitButton());
expect(errorFor("phoneNumber")).toContainText(
errors.phoneNumber
);
});
if (result.ok) {
setError(false);
const customerWithId = await result.json();
onSave(customerWithId);
} else if (result.status === 422) {
const response = await result.json();
setValidationErrors(response.errors);
} else {
setError(true);
}
Your tests should now be passing.
This section has shown you how to integrate server-side errors into the same client-side validation logic that you already have. To finish up, we’ll add some frills.
It’d be great if we could indicate to the user that their form data is being sent to our application servers. The GitHub repository for this book contains a spinner graphic and some CSS that we can use. All that our React component needs to do is display a span element with a class name of submittingIndicator.
Before we write out the tests, let’s look at how the production code will work. We will introduce a new submitting boolean state variable that is used to toggle between states. It will be toggled to true just before we perform the fetch request and toggled to false once the request completes. Here’s how we’ll modify handleSubmit:
...
if (!anyErrors(validationResult)) {
setSubmitting(true);
const result = await global.fetch(...);
setSubmitting(false);
...
}
...
If submitting is set to true, then we will render the spinner graphic. Otherwise, we will render nothing.
One of the trickiest aspects of testing React components is testing what happens during a task. That’s what we need to do now: we want to check that the submitting indicator is shown while the form is being submitted. However, the indicator disappears as soon as the promise completes, meaning that we can’t use the standard clickAndWait function we’ve used up until now because it will return at the point after the indicator has disappeared!
Recall that clickAndWait uses the asynchronous form of the act test helper. That’s the core of the issue. To get around this, a synchronous form of our function, click, will be needed to return before the task queue completes – in other words, before the global.fetch call returns any results.
However, to stop React’s warning sirens from going off, we still need to include the asynchronous act form somewhere in our test. React knows the submit handler returns a promise and it expects us to wait for its execution via a call to act. We need to do that after we’ve checked the toggle value of submitting, not before.
Let’s build that test now:
import { act } from "react-dom/test-utils";
import {
...,
click,
clickAndWait,
} from "./reactTestExtensions";
describe("submitting indicator", () => {
it("displays when form is submitting", async () => {
render(
<CustomerForm
original={validCustomer}
onSave={() => {}}
/>
);
click(submitButton());
await act(async () => {
expect(
element("span.submittingIndicator")
).not.toBeNull();
});
});
});
return (
<form id="customer" onSubmit={handleSubmit}>
...
<input type="submit" value="Add" />
<span className="submittingIndicator" />
</form>
);
it("initially does not display the submitting indicator", () => {
render(<CustomerForm original={validCustomer} />);
expect(element(".submittingIndicator")).toBeNull();
});
const [submitting, setSubmitting] = useState(false);
{submitting ? (
<span className="submittingIndicator" />
) : null}
if (!anyErrors(validationResult)) {
setSubmitting(true);
const result = await global.fetch(/* ... */);
...
}
it("hides after submission", async () => {
render(
<CustomerForm
original={validCustomer}
onSave={() => {}}
/>
);
await clickAndWait(submitButton());
expect(element(".submittingIndicator")).toBeNull();
});
if (!anyErrors(validationResult)) {
setSubmitting(true);
const result = await global.fetch(/* ... */);
...
}
That’s everything; your tests should all be passing.
After this, our handleSubmit function is long – I have counted 23 lines in my implementation. That is too long for my liking!
Refactoring handleSubmit into smaller methods is an exercise left for you; see the Exercises section for more details. But here are a couple of hints for how you can go about that systematically:
Now, let’s summarize this chapter.
This chapter has shown you how TDD can be applied beyond just toy examples. Although you may not ever want to implement form validation yourself, you can see how complex code can be test-driven using the same methods that you learned in the first part of this book.
First, you learned how to validate field values at an appropriate moment: when fields lose focus and when forms are submitted. You also saw how server-side errors can be integrated into that, and how to display an indicator to show the user that data is in the process of being saved.
This chapter also covered how to move logic from your React components into their own modules.
In the next chapter, we’ll add a new feature to our system: a snazzy search interface.
The following are some exercises for you to complete:
To learn more about the topics that were covered in this chapter, take a look at the following resources:
https://reacttdd.com/testing-regular-expressions
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Annotations
In this chapter, we’ll continue applying the techniques we’ve already learned to another, more complex use case.
As we work through the chapter, we’ll learn how to adjust a component’s design using tests to show us where the design is lacking. Test-driven development really helps highlight design issues when the tests get knarly. Luckily, the tests we’ve already written give us the confidence to change course and completely reinvent our design. With each change, we simply run npm test and have our new implementation verified in a matter of seconds.
In the current workflow, users start by adding a new customer and then immediately book an appointment for that customer. Now, we’ll expand on that by allowing them to choose an existing customer before adding an appointment.
We want users to be able to quickly search through customers. There could be hundreds, maybe thousands, of customers registered with this salon. So, we’ll build a CustomerSearch search component that will allow our users to search for customers by name and to page through the returned results.
In this chapter, you’ll learn about the following topics:
The following screenshot shows how the new component will look:
Figure 10.1 – The new CustomerSearch component
By the end of the chapter, you’ll have built a relatively complex component using all the techniques you’ve learned so far.
The code files for this chapter can be found here:
In this section, we’ll get the basic form of the table in place, with an initial set of data retrieved from the server when the component is mounted.
The server application programming interface (API) supports GET requests to /customers. There is a searchTerm parameter that takes the string the user is searching for. There is also an after parameter that is used to retrieve the next page of results. The response is an array of customers, as shown here:
[{ id: 123, firstName: "Ashley"}, ... ]
Sending a request to /customers with no parameters will return the first 10 of our customers, in alphabetical order by first name.
This gives us a good place to start. When the component mounts, we’ll perform this basic search and display the results in a table.
Skipping the starting point
If you’re following along using the GitHub repository, be aware that this chapter starts with a barebones CustomerSearch component already implemented, and it has already been hooked up to the App component. The component is displayed by clicking on the Search appointments button in the top menu.
Let’s start with our first test for the new CustomerSearch component. Follow these steps:
it("renders a table with four headings", async () => {
await renderAndWait(<CustomerSearch />);
const headings = elements("table th");
expect(textOf(headings)).toEqual([
"First name",
"Last name",
"Phone number",
"Actions",
]);
});
export const CustomerSearch = () => (
<table>
<thead>
<tr>
<th>First name</th>
<th>Last name</th>
<th>Phone number</th>
<th>Actions</th>
</tr>
</thead>
</table>
);
it("fetches all customer data when component mounts", async () => {
await renderAndWait(<CustomerSearch />);
expect(global.fetch).toBeCalledWith("/customers", {
method: "GET",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
});
});
export const CustomerSearch = () => {
useEffect(() => {
const fetchData = async () =>
await global.fetch("/customers", {
method: "GET",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
});
fetchData();
}, []);
return (
...
)
};
const oneCustomer = [
{
id: 1,
firstName: "A",
lastName: "B",
phoneNumber: "1"
},
];
it("renders all customer data in a table row", async () => {
global.fetch.mockResolvedValue(
fetchResponseOk(oneCustomer)
);
await renderAndWait(<CustomerSearch />);
const columns = elements("table > tbody > tr > td");
expect(columns[0]).toContainText("A");
expect(columns[1]).toContainText("B");
expect(columns[2]).toContainText("1");
});
const [customers, setCustomers] = useState([]);
const fetchData = async () => {
const result = await global.fetch(...);
setCustomers(await result.json());
};
const CustomerRow = ({ customer }) => (
<tr>
<td>{customer.firstName}</td>
<td>{customer.lastName}</td>
<td>{customer.phoneNumber}</td>
<td />
</tr>
);
return (
<table>
<thead>
...
</thead>
<tbody>
{customers[0] ? (
<CustomerRow customer={customers[0]} />
) : null}
</tbody>
</table>
);
const twoCustomers = [
{
id: 1,
firstName: "A",
lastName: "B",
phoneNumber: "1"
},
{
id: 2,
firstName: "C",
lastName: "D",
phoneNumber: "2"
}
];
it("renders multiple customer rows", async () => {
global.fetch.mockResolvedValue(
fetchResponseOk(twoCustomers)
);
await renderAndWait(<CustomerSearch />);
const rows = elements("table tbody tr");
expect(rows[1].childNodes[0]).toContainText("C");
});
<tbody>
{customers.map(customer => (
<CustomerRow
customer={customer}
key={customer.id}
/>
)
)}
</tbody>
This gives us a great base to build on for the remaining functionality we’ll build in this chapter.
In the next section, we’ll introduce the ability to move between multiple pages of search results.
By default, our endpoint returns 10 records. To get the next 10 records, we can page through the result set by using the after parameter, which represents the last customer identifier seen. The server will skip through results until it finds that ID and returns results from the next customer onward.
We’ll add Next and Previous buttons that will help us move between search results. Clicking Next will take the ID of the last customer record currently shown on the page and send it as the after parameter to the next search request.
To support Previous, we’ll need to maintain a stack of after IDs that we can pop each time the user clicks Previous.
Let’s start with the Next button, which the user can click to bring them to the next page of results. Since we’re going to be dealing with multiple buttons on the screens, we’ll build a new buttonWithLabel helper that will match a button with that label. Follow these steps:
export const buttonWithLabel = (label) =>
elements("button").find(
({ textContent }) => textContent === label
);
import {
...,
buttonWithLabel,
} from "./reactTestExtensions";
it("has a next button", async () => {
await renderAndWait(<CustomerSearch />);
expect(buttonWithLabel("Next")).not.toBeNull();
});
const SearchButtons = () => (
<menu>
<li>
<button>Next</button>
</li>
</menu>
);
return (
<>
<SearchButtons />
<table>
...
</table>
</>
);
const tenCustomers =
Array.from("0123456789", id => ({ id })
);
Making good use of Array.from
This definition uses a “clever” version of the Array.from function that takes each character of the string and creates an object using that character as input. We end up with 10 objects, each with an id property ranging from 0 to 9.
it("requests next page of data when next button is clicked", async () => {
global.fetch.mockResolvedValue(
fetchResponseOk(tenCustomers)
);
await renderAndWait(<CustomerSearch />);
await clickAndWait(buttonWithLabel("Next"));
expect(global.fetch).toHaveBeenLastCalledWith(
"/customers?after=9",
expect.anything()
);
});
Avoiding unnecessary fields to highlight important implications
The tenCustomers value is only a partial definition for each customer: only the id property is included. That’s not lazy: it’s intentional. Because the logic of taking the last ID is non-obvious, it’s important to highlight the id property as the key feature of this flow. We won’t worry about the other fields because our previous tests check their correct usage.
const handleNext = useCallback(() => {
const after = customers[customers.length - 1].id;
const url = `/customers?after=${after}`;
global.fetch(url, {
method: "GET",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }
});
}, [customers]);
const SearchButtons = ({ handleNext }) => (
<menu>
<li>
<button onClick={handleNext}>Next</button>
</li>
</menu>
);
<SearchButtons handleNext={handleNext} />
it("displays next page of data when next button is clicked", async () => {
const nextCustomer = [{ id: "next", firstName: "Next" }];
global.fetch
.mockResolvedValueOnce(
fetchResponseOk(tenCustomers)
)
.mockResolvedValue(fetchResponseOk(nextCustomer));
await renderAndWait(<CustomerSearch />);
await clickAndWait(buttonWithLabel("Next"));
expect(elements("tbody tr")).toHaveLength(1);
expect(elements("td")[0]).toContainText("Next");
});
const handleNext = useCallback(async () => {
...
const result = await global.fetch(...);
setCustomers(await result.json());
}, [customers]);
That’s it for our Next button. Before we move on to the Previous button, we need to correct a design issue.
Look here at the similarities between the handleNext and fetchData functions. They are almost identical; the only place they differ is in the first parameter to the fetch call. The handleNext function has an after parameter; fetchData has no parameters:
const handleNext = useCallback(async () => {
const after = customers[customers.length - 1].id;
const url = `/customers?after=${after}`;
const result = await global.fetch(url, ...);
setCustomers(await result.json());
}, [customers]);
const fetchData = async () => {
const result = await global.fetch(`/customers`, ...);
setCustomers(await result.json());
};
We will be adding a Previous button, which would result in further duplication if we carried on with this same design. But there’s an alternative. We can take advantage of the useEffect hook’s ability to rerun when the state changes.
We will introduce a new state variable, queryString, which handleNext will update and useEffect will listen for.
Let’s do that now. Proceed as follows:
const [queryString, setQueryString] = useState("");
const handleNext = useCallback(() => {
const after = customers[customers.length - 1].id;
const newQueryString = `?after=${after}`;
setQueryString(newQueryString);
}, [customers]);
useEffect(() => {
const fetchData = async () => {
const result = await global.fetch(
`/customers${queryString}`,
...
);
setCustomers(await result.json());
};
fetchData();
}, [queryString]);
That’s it for the Next button: you’ve seen how to write elegant tests for a complex piece of API orchestration logic, and we’ve refactored our production code to be elegant, too.
Let’s move on to the Previous button:
it("has a previous button", async () => {
await renderAndWait(<CustomerSearch />);
expect(buttonWithLabel("Previous")).not.toBeNull();
});
<menu>
<li>
<button>Previous</button>
</li>
...
</menu>
it("moves back to first page when previous button is clicked", async () => {
global.fetch.mockResolvedValue(
fetchResponseOk(tenCustomers)
);
await renderAndWait(<CustomerSearch />);
await clickAndWait(buttonWithLabel("Next"));
await clickAndWait(buttonWithLabel("Previous"));
expect(global.fetch).toHaveBeenLastCalledWith(
"/customers",
expect.anything()
);
});
const handlePrevious = useCallback(
() => setQueryString(""),
[]
);
const SearchButtons = (
{ handleNext, handlePrevious }
) => (
<menu>
<li>
<button
onClick={handlePrevious}
>
Previous
</button>
</li>
...
</menu>
);
<SearchButtons
handleNext={handleNext}
handlePrevious={handlePrevious}
/>
const anotherTenCustomers =
Array.from("ABCDEFGHIJ", id => ({ id }));
it("moves back one page when clicking previous after multiple clicks of the next button", async () => {
global.fetch
.mockResolvedValueOnce(
fetchResponseOk(tenCustomers)
)
.mockResolvedValue(
fetchResponseOk(anotherTenCustomers)
);
await renderAndWait(<CustomerSearch />);
await clickAndWait(buttonWithLabel("Next"));
await clickAndWait(buttonWithLabel("Next"));
await clickAndWait(buttonWithLabel("Previous"));
expect(global.fetch).toHaveBeenLastCalledWith(
"/customers?after=9",
expect.anything()
);
});
const [
previousQueryString, setPreviousQueryString
] = useState("");
Forcing design issues
You may recognize this as an overly complicated design. Let’s just go with it for now: we will simplify this again with another test.
const handleNext = useCallback(queryString => {
...
setPreviousQueryString(queryString);
setQueryString(newQueryString);
}, [customers, queryString]);
const handlePrevious = useCallback(async () =>
setQueryString(previousQueryString)
, [previousQueryString]);
That’s it for a basic Previous button implementation. However, what happens when we want to go back two or more pages? Our current design only has a “depth” of two additional pages. What if we want to support any number of pages?
We can use a test to force the design issue. The process of TDD helps us to ensure that we always take time to think about the simplest solution that solves all tests. So, if we add one more test that highlights the limits of the current design, that test becomes a trigger for us to stop, think, and reimplement.
In this case, we can use a stack of previous query strings to remember the history of pages. We’ll replace our two state variables, queryString and previousQueryString, with a single state variable, queryStrings, which is a stack of all previous query strings.
Let’s get started with the test. Follow these steps:
it("moves back multiple pages", async () => {
global.fetch
.mockResolvedValue(fetchResponseOk(tenCustomers));
await renderAndWait(<CustomerSearch />);
await clickAndWait(buttonWithLabel("Next"));
await clickAndWait(buttonWithLabel("Next"));
await clickAndWait(buttonWithLabel("Previous"));
await clickAndWait(buttonWithLabel("Previous"));
expect(global.fetch).toHaveBeenLastCalledWith(
"/customers",
expect.anything()
);
});
const [queryStrings, setQueryStrings] = useState([]);
useEffect(() => {
const fetchData = async () => {
const queryString =
queryStrings[queryStrings.length - 1] || "";
const result = await global.fetch(
`/customers${queryString}`,
...
);
setCustomers(await result.json());
};
fetchData();
}, [queryStrings]);
const handleNext = useCallback(() => {
const after = customers[customers.length - 1].id;
const newQueryString = `?after=${after}`;
setQueryStrings([...queryStrings, newQueryString]);
}, [customers, queryStrings]);
const handlePrevious = useCallback(() => {
setQueryStrings(queryStrings.slice(0, -1));
} [queryStrings]);
You now have a relatively complete implementation for the Next and Previous buttons. You’ve also seen how tests can help you alter your design as you encounter issues with it.
Next, we’ll continue building out our integration with the searchTerm parameter of the /customers HTTP endpoint.
In this section, we’ll add a textbox that the user can use to filter names. Each character that the user types into the search field will cause a new fetch request to be made to the server. That request will contain the new search term as provided by the search box.
The /customers endpoint supports a parameter named searchTerm that filters search results using those terms, as shown in the following code snippet:
GET /customers?searchTerm=Dan
[
{
firstName: "Daniel",
...
}
...
]
Let’s start by adding a text field into which the user can input a search term, as follows:
it("renders a text field for a search term", async () => {
await renderAndWait(<CustomerSearch />);
expect(element("input")).not.toBeNull();
});
return (
<>
<input />
...
</>
);
it("sets the placeholder text on the search term field", async () => {
await renderAndWait(<CustomerSearch />);
expect(
element("input").getAttribute("placeholder")
).toEqual("Enter filter text");
});
<input placeholder="Enter filter text" />
export const changeAndWait = async (target, value) =>
act(async () => change(target, value));
import {
...,
changeAndWait,
} from "./reactTestExtensions";
it("performs search when search term is changed", async () => {
await renderAndWait(<CustomerSearch />);
await changeAndWait(element("input"), "name");
expect(global.fetch).toHaveBeenLastCalledWith(
"/customers?searchTerm=name",
expect.anything()
);
});
const [searchTerm, setSearchTerm] = useState("");
const handleSearchTextChanged = (
{ target: { value } }
) => setSearchTerm(value);
<input
value={searchTerm}
onChange={handleSearchTextChanged}
placeholder="Enter filter text"
/>
const fetchData = async () => {
let queryString = "";
if (searchTerm !== "") {
queryString = `?searchTerm=${searchTerm}`;
} else if (queryStrings.length > 0) {
queryString =
queryStrings[queryStrings.length - 1];
}
...
};
useEffect(() => {
...
}, [queryStrings, searchTerm]);
it("includes search term when moving to next page", async () => {
global.fetch.mockResolvedValue(
fetchResponseOk(tenCustomers)
);
await renderAndWait(<CustomerSearch />);
await changeAndWait(element("input"), "name");
await clickAndWait(buttonWithLabel("Next"));
expect(global.fetch).toHaveBeenLastCalledWith(
"/customers?after=9&searchTerm=name",
expect.anything()
);
});
const fetchData = async () => {
let queryString;
if (queryStrings.length > 0 && searchTerm !== "") {
queryString =
queryStrings[queryStrings.length - 1]
+ `&searchTerm=${searchTerm}`;
} else if (searchTerm !== '') {
queryString = `?searchTerm=${searchTerm}`;
} else if (queryStrings.length > 0) {
queryString =
queryStrings[queryStrings.length - 1];
}
...
};
We’ve made this test pass... but this is a mess! Any if statement with so many moving parts (variables, operators, conditions, and so on) is a signal that the design isn’t as good as it can be. Let’s fix it.
The issue is the queryString data structure and its historical counterpart, the queryStrings state variable. The construction is complex.
How about we just store the original data instead—the ID of the customer in the last table row? Then, we can construct the queryString data structure immediately before fetching, since in reality, queryString is an input to the fetch request only. Keeping the raw data seems like it will be simpler.
Let’s plan out our refactor. At each of the following stages, our tests should still be passing, giving us confidence that we’re still on the right path:
Doesn’t sound so hard, does it? Let’s begin, as follows:
const [lastRowIds, setLastRowIds] = useState([]);
const newQueryString = `?after=${after}`;
const handleNext = useCallback(() => {
const after = customers[customers.length - 1].id;
setLastRowIds([...lastRowIds, after]);
}, [customers, lastRowIds]);
const searchParams = (after, searchTerm) => {
let pairs = [];
if (after) {
pairs.push(`after=${after}`);
}
if (searchTerm) {
pairs.push(`searchTerm=${searchTerm}`);
}
if (pairs.length > 0) {
return `?${pairs.join("&")}`;
}
return "";
};
const fetchData = async () => {
const after = lastRowIds[lastRowIds.length - 1];
const queryString = searchParams(after, searchTerm);
const response = await global.fetch(...);
};
You’ve now built a functional search component. You introduced a new helper, changeAndWait, and extracted out a searchParams function that could be reused in other places.
Next, we’ll add a final mechanism to the CustomerSearch component.
Each row of the table will hold a Create appointment action button. When the user has found the customer that they are searching for, they can press this button to navigate to the AppointmentForm component, creating an appointment for that customer.
We’ll display these actions by using a render prop that is passed to CustomerSearch. The parent component—in our case, App—uses this to insert its own rendering logic into the child component. App will pass a function that displays a button that causes a view transition in App itself.
Render props are useful if the child component should be unaware of the context it’s operating in, such as the workflow that App provides.
Unnecessarily complex code alert!
The implementation you’re about to see could be considered more complex than it needs to be. There are other approaches to solving this problem: you could simply have CustomerSearch render AppointmentFormLoader directly, or you could allow CustomerSearch to render the button and then invoke a callback such as onSelect(customer).
Render props are probably more useful to library authors than to any application authors since library components can’t account for the context they run within.
The testing techniques we need for render props are much more complex than anything we’ve seen so far, which you can take as another sign that there are “better” solutions.
To begin with, we’ll add the renderCustomerActions prop to CustomerSearch and render it in a new table cell. Follow these steps:
it("displays provided action buttons for each customer", async () => {
const actionSpy = jest.fn(() => "actions");
global.fetch.mockResolvedValue(
fetchResponseOk(oneCustomer)
);
await renderAndWait(
<CustomerSearch
renderCustomerActions={actionSpy}
/>
);
const rows = elements("table tbody td");
expect(rows[rows.length - 1])
.toContainText("actions");
});
CustomerSearch.defaultProps = {
renderCustomerActions: () => {}
};
export const CustomerSearch = (
{ renderCustomerActions }
) => {
...
};
<CustomerRow
customer={customer}
key={customer.id}
renderCustomerActions={renderCustomerActions}
/>
const CustomerRow = (
{ customer, renderCustomerActions }
) => (
<tr>
<td>{customer.firstName}</td>
<td>{customer.lastName}</td>
<td>{customer.phoneNumber}</td>
<td>{renderCustomerActions()}</td>
</tr>
);
it("passes customer to the renderCustomerActions prop", async () => {
const actionSpy = jest.fn(() => "actions");
global.fetch.mockResolvedValue(
fetchResponseOk(oneCustomer)
);
await renderAndWait(
<CustomerSearch
renderCustomerActions={actionSpy}
/>
);
expect(actionSpy).toBeCalledWith(oneCustomer[0]);
});
<td>{renderCustomerActions(customer)}</td>
That’s all there is to invoking the render prop inside the CustomerSearch component. Where it gets difficult is test-driving the implementation of the render prop itself, in the App component.
Recall that the App component has a view state variable that determines which component the user is currently viewing on the screen. If they are searching for customers, then view will be set to searchCustomers.
Pressing the Create appointment button on the CustomerSearch component should have the effect of setting view to addAppointment, causing the user’s screen to hide the CustomerSearch component and show the AppointmentForm component.
We also need to set the App component’s customer state variable to the customer that the user just selected in the CustomerSearch component.
All of this will be done in the render prop that App passes to customer.
The big question is: how do we test-drive the implementation of this render prop?
There are a few different ways we could do it:
If we use our render and renderAndWait functions to render this additional prop, it will replace the rendered App component. We would then click the button and we’d observe nothing happening because App has gone.
What we need is a second React root that can be used to just render that additional piece of the DOM. Our test can simply pretend that it is the CustomerSearch component.
To do this, we need a new render component that we’ll call renderAdditional. Let’s add that now and then write our test, as follows:
export const renderAdditional = (component) => {
const container = document.createElement("div");
act(() =>
ReactDOM.createRoot(container).render(component)
);
return container;
};
import {
...,
renderAdditional,
} from "./reactTestExtensions";
const searchFor = (customer) =>
propsOf(CustomerSearch)
.renderCustomerActions(customer);
it("passes a button to the CustomerSearch named Create appointment", async () => {
render(<App />);
navigateToSearchCustomers();
const buttonContainer =
renderAdditional(searchFor());
expect(
buttonContainer.firstChild
).toBeElementWithTag("button");
expect(
buttonContainer.firstChild
).toContainText("Create appointment");
});
const searchActions = () => (
<button>Create appointment</button>
);
case "searchCustomers":
return (
<CustomerSearch
renderCustomerActions={searchActions}
/>
);
it("clicking appointment button shows the appointment form for that customer", async () => {
const customer = { id: 123 };
render(<App />);
navigateToSearchCustomers();
const buttonContainer = renderAdditional(
searchFor(customer)
);
click(buttonContainer.firstChild);
expect(
element("#AppointmentFormLoader")
).not.toBeNull();
expect(
propsOf(AppointmentFormLoader).original
).toMatchObject({ customer: 123 });
});
const searchActions = (customer) => (
<button
onClick={
() => transitionToAddAppointment(customer)
}>
Create appointment
</button>
);
That’s all there is to it: you’ve now used renderAdditional to trigger your render props and check that it works as expected.
This technique can be very handy when working with third-party libraries that expect you to pass render props.
That completes this feature; go ahead and manually test if you’d like to see it all in action.
This chapter has explored building out a component with some complex user interactions between the user interface and an API. You’ve created a new table component and integrated it into the existing application workflow.
You have seen how to make large changes to your component’s implementation, using your tests as a safety mechanism.
You have also seen how to test render props using an additional render root—a technique that I hope you don’t have to use too often!
In the next chapter, we’ll use tests to integrate React Router into our application. We’ll continue with the CustomerSearch component by adding the ability to use the browser location bar to specify search criteria. That will set us up nicely for introducing Redux and GraphQL later on.
React Router is a popular library of components that integrate with the browser’s own navigation system. It manipulates the browser’s address bar so that changes in your UI appear as page transitions. To the user, it seems like they are navigating between separate pages. In reality, they remain on the same page and avoid an expensive page reload.
In this chapter, we’ll refactor our example appointments system to make use of React Router. Unlike the rest of the book, this chapter is not a walkthrough. That’s because the refactoring process is quite long and laborious. Instead, we’ll look at each of the main changes in turn.
This chapter covers the following:
By the end of the chapter, you’ll have learned all the necessary techniques for test-driving React Router integrations.
The code files for this chapter can be found here:
This section is a run-down of all the major pieces of the React Router ecosystem, just in case you’re not familiar with it. It also contains guidance on how to test a system that relies on React Router.
Here’s what you’ll be working with from the React Router library:
You can see from this list that React Router’s core function is to manipulate the window location and modify your application’s behavior based on that location.
One way to think about this is that we will utilize the window location as a form of application state that is accessible to all our components. Importantly, this state persists across web requests, because a user can save or bookmark links for use later.
A consequence of this is that we must now split apart some of our unit tests. Take, for example, the Create appointment button that was previously used to switch out the main component on display on the page. With React Router in place, this button will become a link. Previously, we had a single unit test named as follows:
displays the AppointmentFormLoader after the CustomerForm is submitted
But now, we’ll split that into two tests:
navigates to /addAppointment after the CustomerForm is submitted renders AppointmentFormRoute at /addAppointment
You can see that the first test stops at the moment the window location changes. The second test begins at the moment the browser navigates to the same location.
It’s important to make this change because React Router isn’t just refactoring, it’s adding a new feature: the URL is now accessible as an entry point into your application.
That is, in essence, the most important thing you need to know before introducing React Router into your projects.
Before launching into this refactor, let’s take a look at the routes we’ll be introducing:
?searchTerm=An&limit=20&previousRowIds=123,456
Next, we’ll look at test-driving a Router component along with its Route children.
In this section, we’ll look at how to use the primary Router, Routes, and Route components.
No walkthrough in this chapter
As mentioned in the chapter introduction, this chapter does not follow the usual walkthrough approach. The examples shown here are taken from the completed refactoring of our Appointments code base, which you’ll find in the Chapter11/Complete directory of the GitHub repository.
This is a top-level component that hooks into your browser’s location mechanics. We do not generally test drive this because JSDOM doesn’t deal with page transitions, or have full support for the window.location API.
Instead, we put it in the src/index.js file:
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
This is necessary because if you try to use any of the other React Router components outside of a child of a Router component, it will blow up. The same is true for our tests: our components need to be rendered inside of a router. So, we introduce a new render helper called renderWithRouter.
This definition is within test/reactTestExtensions.js:
import { createMemoryHistory } from "history";
import {
unstable_HistoryRouter as HistoryRouter
} from "react-router-dom";
export let history;
export const renderWithRouter = (
component,
{ location } = { location: "" }
) => {
history = createMemoryHistory({
initialEntries: [location]
});
act(() =>
reactRoot.render(
<HistoryRouter history={history}>
{component}
</HistoryRouter>
)
);
};
MemoryRouter versus HistoryRouter
The React Router documentation will suggest you use MemoryRouter, which is often good enough. Using HistoryRouter allows you to control the history instance that is passed in, meaning you can manipulate it from within your tests.
For more information, take a look at https://reacttdd.com/memory-router-vs-history-router.
It’s important to export the history variable itself if you want to manipulate the window location from within your own tests. A special case of this is if you want to set the window location before mounting the component; in this situation, you can simply pass a location property to the renderWithRouter function. You’ll see how this works next.
Now let’s look at using the Routes component to switch components depending on the window location. This component is generally at the top of the application component hierarchy, and in our case, it is indeed the first component within App.
The Routes component is analogous to the switch statement that existed in the original app. The switch statement was using a state variable to determine which component should be shown. The Routes component relies on the parent Router to feed it the window location as context.
Here’s what the original switch statement looked like in the App component:
const [view, setView] = useState("dayView");
...
switch (view) {
case "addCustomer":
return (
<CustomerForm ... />
);
case "searchCustomers":
return (
<CustomerSearch ... />
);
case "addAppointment":
return (
<AppointmentFormLoader ... />
);
default:
return ...
}
Its Router replacement looks like this:
<Routes>
<Route
path="/addCustomer"
element={<CustomerForm ... />}
/>
<Route
path="/addAppointment"
element={<AppointmentFormRoute ... />}
/>
<Route
path="/searchCustomers"
element={<CustomerSearchRoute ... />}
/>
<Route path="/" element={<MainScreen />} />
</Routes>
The view state variable is no longer needed. Notice how we have a couple of new components with a Route suffix. These components are small wrappers that pull out the customer ID and other parameters from the window location before passing it to the original components. We’ll look at those soon.
But first, how do the tests look for these new routes?
For the default route, the tests are simple, and are updates to the tests that were there before:
it("initially shows the AppointmentDayViewLoader", () => {
renderWithRouter(<App />);
expect(AppointmentsDayViewLoader).toBeRendered();
});
it("has a menu bar", () => {
renderWithRouter(<App />);
expect(element("menu")).not.toBeNull();
});
The only difference is that we use the renderWithRouter helper, not render.
The other routes are similar, except that they use the location property to set the initial window location, and their assertions are based on mocked components:
it("renders CustomerForm at the /addCustomer endpoint", () => {
renderWithRouter(<App />, {
location: "/addCustomer"
});
expect(CustomerForm).toBeRendered();
});
it("renders AppointmentFormRoute at /addAppointment", () => {
renderWithRouter(<App />, {
location: "/addAppointment?customer=123",
});
expect(AppointmentFormRoute).toBeRendered();
});
it("renders CustomerSearchRoute at /searchCustomers", () => {
renderWithRouter(<App />, {
location: "/searchCustomers"
});
expect(CustomerSearchRoute).toBeRendered();
});
Let’s take a closer look at AppointmentFormRoute and CustomerSearchRoute. What are these components doing?
Here’s the definition of AppointmentFormRoute:
import React from "react";
import { useSearchParams } from "react-router-dom";
import {
AppointmentFormLoader
} from "./AppointmentFormLoader";
const blankAppointment = {
service: "",
stylist: "",
startsAt: null,
};
export const AppointmentFormRoute = (props) => {
const [params, _] = useSearchParams();
return (
<AppointmentFormLoader
{...props}
original={{
...blankAppointment,
customer: params.get("customer"),
}}
/>
);
};
This component is an intermediate component that sits between the Route component instance for /addAppointment and the AppointmentFormLoader component instance.
It would have been possible to simply reference the useSearchParams function from within AppointmentFormLoader itself, but by using this intermediate class, we can avoid modifying that component and keep the two responsibilities separate.
Having a single responsibility per component helps with comprehension. It also means that should we ever wish to rip out React Router at a later date, AppointmentFormLoader doesn’t need to be touched.
There are a couple of interesting tests for this component. The first is the check for parsing the customer search parameter:
it("adds the customer id into the original appointment object", () => {
renderWithRouter(<AppointmentFormRoute />, {
location: "?customer=123",
});
expect(AppointmentFormLoader).toBeRenderedWithProps({
original: expect.objectContaining({
customer: "123",
}),
});
});
The location property sent to renderWithRouter is just a standard query string: ?customer=123. We could have entered a full URL here, but the test is clearer by focusing purely on the query string portion of the URL.
The second test is for the remainder of the props:
it("passes all other props through to AppointmentForm", () => {
const props = { a: "123", b: "456" };
renderWithRouter(<AppointmentFormRoute {...props} />);
expect(AppointmentFormLoader).toBeRenderedWithProps(
expect.objectContaining({
a: "123",
b: "456",
})
);
});
The test is important because the Route element passes through an onSave property that is for AppointmentFormLoader:
<Route
path="/addAppointment"
element={
<AppointmentFormRoute onSave={transitionToDayView} />
}
/>
We’ll look at what the transitionToDayView function does in the Testing navigation section a little further on.
Now let’s see CustomerSearchRoute. This is a little more complicated because it parses some of the query string parameters, using a function called convertParams:
const convertParams = () => {
const [params] = useSearchParams();
const obj = {};
if (params.has("searchTerm")) {
obj.searchTerm = params.get("searchTerm");
}
if (params.has("limit")) {
obj.limit = parseInt(params.get("limit"), 10);
}
if (params.has("lastRowIds")) {
obj.lastRowIds = params
.get("lastRowIds")
.split(",")
.filter((id) => id !== "");
}
return obj;
};
This function replaces the three state variables that were used in the existing CustomerSearch component. Since all query string parameters are strings, each value needs to be parsed into the right format. These values are then passed into CustomerSearch as props:
import React from "react";
import {
useNavigate,
useSearchParams,
} from "react-router-dom";
import {
CustomerSearch
} from "./CustomerSearch/CustomerSearch";
const convertParams = ...; // as above
export const CustomerSearchRoute = (props) => (
<CustomerSearch
{...props}
navigate={useNavigate()}
{...convertParams()}
/>
);
This parameter parsing functionality could have been put into CustomerSearch, but keeping that logic in a separate component helps with readability.
This example also shows the use of useNavigate, which is passed through to CustomerSearch. Passing this hook function return value as a prop means we can test CustomerSearch with a standard Jest spy function for the value of navigate, avoiding the need to render the test component within a router.
The tests for this component are straightforward. Let’s take a look at one example:
it("parses lastRowIds from query string", () => {
const location =
"?lastRowIds=" + encodeURIComponent("1,2,3");
renderWithRouter(<CustomerSearchRoute />, { location });
expect(CustomerSearch).toBeRenderedWithProps(
expect.objectContaining({
lastRowIds: ["1", "2", "3"],
})
);
});
You’ve now learned all there is to working with the three components: Router, Routes, and Route. Next up is the Link component.
In this section, you’ll learn how to use and test the Link component. This component is React Router’s version of the humble HTML anchor (or a) tag.
There are two forms of the Link component that we use. The first uses the to prop as a string, for example, /addCustomer:
<Link to="/addCustomer" role="button"> Add customer and appointment </Link>
The second sets the to prop to an object with a search property:
<Link
to={{
search: objectToQueryString(queryParams),
}}
>
{children}
</Link>
This object form also takes a pathname property, but we can avoid setting that since the path remains the same for our use case.
We’ll look at two different ways of testing links: the standard way (by checking for hyperlinks), and the slightly more painful way of using mocks.
Here’s the MainScreen component in src/App.js, which shows the navigation links and the appointments day view:
export const MainScreen = () => ( <> <menu> <li> <Link to="/addCustomer" role="button"> Add customer and appointment </Link> </li> <li> <Link to="/searchCustomers" role="button"> Search customers </Link> </li> </menu> <AppointmentsDayViewLoader /> </> );
Extracted component
The MainScreen component has been extracted out of App. The same code previously lived in the switch statement as the default case.
The Link component generates a standard HTML anchor tag. This means we create a helper to find a specific link by looking for an anchor tag with a matching href attribute. This is in test/reactTestExtensions.js:
export const linkFor = (href) =>
elements("a").find(
(el) => el.getAttribute("href") === href
);
That can be then used to test for the presence of a link and its caption:
it("renders a link to the /addCustomer route", async () => {
renderWithRouter(<App />);
expect(linkFor("/addCustomer")).toBeDefined();
});
it("captions the /addCustomer link as 'Add customer and appointment'", async () => {
renderWithRouter(<App />);
expect(linkFor("/addCustomer")).toContainText(
"Add customer and appointment"
);
});
Another way to test this would be to click the link and check that it works, as shown in the following test. However, as mentioned at the beginning of this chapter, this test isn’t necessary because you’ve already tested the two “halves” of this test: that the link is displayed, and that navigating to the URL renders the right component:
it("displays the CustomerSearch when link is clicked", async () => {
renderWithRouter(<App />);
click(linkFor("/searchCustomers"));
expect(CustomerSearchRoute).toBeRendered();
});
That covers the main way to test Link components. Another way to test links is to mock the Link component, which we’ll cover next.
This method is slightly more complicated than simply testing for HTML hyperlinks. However, it does mean you can avoid rendering your component under test within a Router component.
The src/CustomerSearch/RouterButton.js file contains this component:
import React from "react";
import {
objectToQueryString
} from "../objectToQueryString";
import { Link } from "react-router-dom";
export const RouterButton = ({
queryParams,
children,
disabled,
}) => (
<Link
className={disabled ? "disabled" : ""}
role="button"
to={{
search: objectToQueryString(queryParams),
}}
>
{children}
</Link>
);
To test this using plain render, instead of renderWithRouter, we’ll need to mock out the Link component. Here’s how that looks in test/CustomerSearch/RouterButton.test.js:
import { Link } from "react-router-dom";
import {
RouterButton
} from "../../src/CustomerSearch/RouterButton";
jest.mock("react-router-dom", () => ({
Link: jest.fn(({ children }) => (
<div id="Link">{children}</div>
)),
}));
Now, you can happily use that mock in your test:
it("renders a Link", () => {
render(<RouterButton queryParams={queryParams} />);
expect(Link).toBeRenderedWithProps({
className: "",
role: "button",
to: {
search: "?a=123&b=234",
},
});
});
There’s one final piece to think about. Sometimes, you have a single mocked component that has multiple rendered instances on the same page, and this happens frequently with Link instances.
In our case, this is the SearchButtons component, which contains a list of RouterButton and ToggleRouterButton components:
<menu>
...
<li>
<RouterButton
id="previous-page"
queryParams={previousPageParams()}
disabled={!hasPrevious}
>
Previous
</RouterButton>
</li>
<li>
<RouterButton
id="next-page"
queryParams={nextPageParams()}
disabled={!hasNext}
>
Next
</RouterButton>
</li>
</menu>
When it comes to testing these links, the simplest approach is to use renderWithRouter to render the SearchButtons components and then check the rendered HTML hyperlinks.
However, if you’ve decided to mock, then you need a way to easily find the element you’ve rendered.
First, you’d specify the mock to include an id property:
jest.mock("../../src/CustomerSearch/RouterButton", () => ({
RouterButton: jest.fn(({ id, children }) => (
<div id={id}>{children}</div>
)),
}));
Then, you can use a new test extension called propsMatching to find the specific instance. Here’s the definition from test/reactTestExtensions.js:
export const propsMatching = (mockComponent, matching) => {
const [k, v] = Object.entries(matching)[0];
const call = mockComponent.mock.calls.find(
([props]) => props[k] === v
);
return call?.[0];
};
You can then write your test to make use of that, as shown in the following code. Remember though, it’s probably going to be easier not to mock this component and simply use renderWithRouter, and then check the HTML hyperlinks directly:
const previousPageButtonProps = () =>
propsMatching(RouterButton, { id: "previous-page" });
it("renders", () => {
render(<SearchButtons {...testProps} />);
expect(previousPageButtonProps()).toMatchObject({
disabled: false,
});
expect(element("#previous-page")).toContainText(
"Previous"
);
});
That’s everything there is to testing the Link component. In the next section, we’ll look at the final aspect of testing React Router: navigating programmatically.
Sometimes, you’ll want to trigger a location change programmatically—in other words, without waiting for a user to click a link.
There are two ways to do this: one using the useNavigate hook, and the second using a history instance that you pass into your top-level router.
Navigation inside and outside of components
In this chapter, we’ll look at just the first method, using the hook. Later, in Chapter 12, Test-Driving Redux, we’ll use the second method to change the location within a Redux action.
The useNavigate hook is the appropriate method when you’re able to navigate from within a React component.
In the Appointments application, this happens in two places. The first is after a customer has been added and we want to move the user on to the /addAppointment route. The second is after that form has been completed and the appointment has been created—then we want to move them back to the default route.
Since these are very similar, we’ll look at just the first.
Here’s how the /addCustomer route definition looks in src/App.js:
<Route
path="/addCustomer"
element={
<CustomerForm
original={blankCustomer}
onSave={transitionToAddAppointment}
/>
}
/>
Notice the onSave prop; this is the callback that gets called once the customer form submission is completed. Here’s that callback definition, together with the bits relevant for the useNavigate hook:
import {
...,
useNavigate,
} from "react-router-dom";
export const App = () => {
const navigate = useNavigate();
const transitionToAddAppointment = (customer) =>
navigate(`/addAppointment?customer=${customer.id}`);
...
};
When it comes to testing this, clearly, we can’t simply rely on the presence of a Link component, because there isn’t one. Instead, we must call the onSave callback:
import {
...,
history,
} from "./reactTestExtensions";
...
it("navigates to /addAppointment after the CustomerForm is submitted", () => {
renderWithRouter(<App />);
click(linkFor("/addCustomer"));
const onSave = propsOf(CustomerForm).onSave;
act(() => onSave(customer));
expect(history.location.pathname).toEqual(
"/addAppointment"
);
});
The expectation is to test that the history is updated correctly. This history is the exported constant from test/reactTestExtensions.js that is set in the renderWithRouter function that we defined in the Testing components within a router section.
There is a variation of this. Instead of using the history import, you could also simply use the window.location instance:
expect(
window.location.pathname
).toEqual("/addAppointment");
You’ve now learned how to test programmatic React Router navigation.
In the next chapter, Test-Driving Redux, we’ll see how we can use this same history instance from a Redux saga.
This chapter has shown you how to use React Router in a testable fashion. You have learned how to test-drive the Router, Routes, Route, and Link components. You have seen how to use the React Router useSearchParams and useNavigate hooks.
Most importantly, you’ve seen that because routes give an extra level of entry into your application, you must split your existing navigation tests into two parts: one to test that a link exists (or is followed), and one to check that if you visit that URL, the right component is displayed.
Now that we’ve successfully integrated one library, the next one shouldn’t be too tricky, right? In the next chapter, we’ll apply all the skills we’ve learned in this chapter to the integration of another library: Redux.
In this chapter, there was no walkthrough because the refactoring process is quite involved and would have taken up a decent chunk of time and space.
Use this opportunity to try refactoring yourself. Use a systematic refactoring approach to break down the change to React Router into many small steps. At each step, you should still have working software.
You can find a guide on how to approach this type of refactoring at https://reacttdd.com/refactoring-to-react-router.
The official React Router documentation can be found at the following link:
Redux is a predictable state container. To the uninitiated, these words mean very little. Thankfully, TDD can help us understand how to think about and implement our Redux application architecture. The tests in the chapter will help you see how Redux can be integrated into any application.
The headline benefit of Redux is the ability to share state between components in a way that provides data consistency when operating in an asynchronous browser environment. The big drawback is that you must introduce a whole bunch of plumbing and complexity into your application.
Here be dragons
For many applications, the complexity of Redux outweighs the benefits. Just because this chapter exists in this book does not mean you should be rushing out to use Redux. In fact, I hope that the code samples contained herein serve as warning enough for the complexity you will be introducing.
In this chapter, we’ll build a reducer and a saga to manage the submission of our CustomerForm component.
We’ll use a testing library named expect-redux to test Redux interactions. This library allows us to write tests that are not tied to the redux-saga library. Being independent of libraries is a great way of ensuring that your tests are not brittle and are resilient to change: you could replace redux-saga with redux-thunk and your tests would still work.
This chapter covers the following topics:
By the end of the chapter, you’ll have seen all the techniques you need for testing Redux.
The code files for this chapter can be found here:
In this section, we’ll do the usual thing of mapping out a rough plan of what we’re going to build.
Let’s start by looking at what the actual technical change is going to be and discuss why we’re going to do it.
We’re going to move the logic for submitting a customer—the doSave function in CustomerForm—out of the React component and into Redux. We’ll use a Redux reducer to manage the status of the operation: whether it’s currently submitting, finished, or had a validation error. We’ll use a Redux saga to perform the asynchronous operation.
Given the current feature set of the application, there’s really no reason to use Redux. However, imagine that in the future, we’d like to support these features:
In this future scenario, it might make sense to have some shared Redux state for the customer data.
I say “might” because there are other, potentially simpler solutions: component context, or perhaps some kind of HTTP response caching. Who knows what the solution would look like? It’s too hard to say without a concrete requirement.
To sum up: in this chapter, we’ll use Redux to store customer data. It has no real benefit over our current approach, and in fact, has the drawback of all the additional plumbing. However, let’s press on, given that the purpose of this book is educational.
A Redux store is simply an object of data with some restrictions on how it is accessed. Here’s how we want ours to look. The object encodes all the information that CustomerForm already uses about a fetch request to save customer data:
{
customer: {
status: SUBMITTING | SUCCESSFUL | FAILED | ...
// only present if the customer was saved successfully
customer: { id: 123, firstName: "Ashley" ... },
// only present if there are validation errors
validationErrors: { phoneNumber: "..." },
// only present if there was another type of error
error: true | false
}
}
Redux changes this state by means of named actions. We will have the following actions:
For reference, here’s the existing code that we’ll be extracting from CustomerForm. It’s all helpfully in one function, doSave, even though it is quite long:
const doSave = async () => {
setSubmitting(true);
const result = await global.fetch("/customers", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(customer),
});
setSubmitting(false);
if (result.ok) {
setError(false);
const customerWithId = await result.json();
onSave(customerWithId);
} else if (result.status === 422) {
const response = await result.json();
setValidationErrors(response.errors);
} else {
setError(true);
}
};
We’ll replace all this code with a combination of a saga and reducer. We’ll start with the reducer, in the next section.
In this section, we’ll test-drive a new reducer function, and then pull out some repeated code.
A reducer is a simple function that takes an action and the current store state as input and returns a new state object as output. Let’s build that now, as follows:
import { reducer } from "../../src/reducers/customer";
describe("customer reducer", () => {
it("returns a default state for an undefined existing state", () => {
expect(reducer(undefined, {})).toEqual({
customer: {},
status: undefined,
validationErrors: {},
error: false
});
});
});
const defaultState = {
customer: {},
status: undefined,
validationErrors: {},
error: false
};
export const reducer = (state = defaultState, action) => {
return state;
};
describe("ADD_CUSTOMER_SUBMITTING action", () => {
const action = { type: "ADD_CUSTOMER_SUBMITTING" };
it("sets status to SUBMITTING", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "SUBMITTING"
});
});
});
switch(action.type) {
case "ADD_CUSTOMER_SUBMITTING":
return { status: "SUBMITTING" };
default:
return state;
}
it("maintains existing state", () => {
expect(reducer({ a: 123 }, action)).toMatchObject({
a: 123
});
});
export const reducer = (state = defaultState, action) => {
switch (action.type) {
case "ADD_CUSTOMER_SUBMITTING":
return { ...state, status: "SUBMITTING" };
default:
return state;
}
};
describe("ADD_CUSTOMER_SUCCESSFUL action", () => {
const customer = { id: 123 };
const action = {
type: "ADD_CUSTOMER_SUCCESSFUL",
customer
};
it("sets status to SUCCESSFUL", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "SUCCESSFUL"
});
});
it("maintains existing state", () => {
expect(
reducer({ a: 123 }, action)
).toMatchObject({ a: 123 });
});
});
case "ADD_CUSTOMER_SUCCESSFUL":
return { ...state, status: "SUCCESSFUL" };
it("sets customer to provided customer", () => {
expect(reducer(undefined, action)).toMatchObject({
customer
});
});
case "ADD_CUSTOMER_SUCCESSFUL":
return {
...state,
status: "SUCCESSFUL",
customer: action.customer
};
describe("ADD_CUSTOMER_FAILED action", () => {
const action = { type: "ADD_CUSTOMER_FAILED" };
it("sets status to FAILED", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "FAILED"
});
});
it("maintains existing state", () => {
expect(
reducer({ a: 123 }, action)
).toMatchObject({ a: 123 });
});
});
case "ADD_CUSTOMER_FAILED":
return { ...state, status: "FAILED" };
it("sets error to true", () => {
expect(reducer(undefined, action)).toMatchObject({
error: true
});
});
case "ADD_CUSTOMER_FAILED":
return { ...state, status: "FAILED", error: true };
describe("ADD_CUSTOMER_VALIDATION_FAILED action", () => {
const validationErrors = { field: "error text" };
const action = {
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors
};
it("sets status to VALIDATION_FAILED", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "VALIDATION_FAILED"
});
});
it("maintains existing state", () => {
expect(
reducer({ a: 123 }, action)
).toMatchObject({ a: 123 });
});
});
case "ADD_CUSTOMER_VALIDATION_FAILED":
return { ...state, status: "VALIDATION_FAILED" };
it("sets validation errors to provided errors", () => {
expect(reducer(undefined, action)).toMatchObject({
validationErrors
});
});
case "ADD_CUSTOMER_VALIDATION_FAILED":
return {
...state,
status: "VALIDATION_FAILED",
validationErrors: action.validationErrors
};
That completes the reducer, but before we use it from within a saga, how about we dry these tests up a little?
Most reducers will follow the same pattern: each action will set some new data to ensure that the existing state is not lost.
Let’s write a couple of test-generator functions to do that for us, to help us dry up our tests. Proceed as follows:
export const itMaintainsExistingState = (reducer, action) => {
it("maintains existing state", () => {
const existing = { a: 123 };
expect(
reducer(existing, action)
).toMatchObject(existing);
});
};
import {
itMaintainsExistingState
} from "../reducerGenerators";
itMaintainsExistingState(reducer, action);
export const itSetsStatus = (reducer, action, value) => {
it(`sets status to ${value}`, () => {
expect(reducer(undefined, action)).toMatchObject({
status: value
});
});
};
import {
itMaintainsExistingState,
itSetsStatus
} from "../reducerGenerators";
describe("ADD_CUSTOMER_SUBMITTING action", () => {
const action = { type: "ADD_CUSTOMER_SUBMITTING" };
itMaintainsExistingState(reducer, action);
itSetsStatus(reducer, action, "SUBMITTING");
});
That concludes the reducer. Before we move on to the saga, let’s tie it into the application. We won’t make use of it at all, but it’s good to get the plumbing in now.
In addition to the reducer we’ve written, we need to define a function named configureStore that we’ll then call when our application starts. Proceed as follows:
import { createStore, combineReducers } from "redux";
import {
reducer as customerReducer
} from "./reducers/customer";
export const configureStore = (storeEnhancers = []) =>
createStore(
combineReducers({ customer: customerReducer }),
storeEnhancers
);
import { Provider } from "react-redux";
import { configureStore } from "./store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={configureStore()}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
With that in place, we’re ready to write the tricky part: the saga.
A saga is a special bit of code that uses JavaScript generator functions to manage asynchronous operations to the Redux store. Because it’s super complex, we won’t actually test the saga itself; instead, we’ll dispatch an action to the store and observe the results.
Before we get started on the saga tests, we need a new test helper function named renderWithStore.
import { Provider } from "react-redux";
import { storeSpy } from "expect-redux";
import { configureStore } from "../src/store";
The expect-redux package
For that, we’ll use the expect-redux package from NPM, which has already been included in the package.json file for you—make sure to run npm install before you begin.
export let store;
export const initializeReactContainer = () => {
store = configureStore([storeSpy]);
container = document.createElement("div");
document.body.replaceChildren(container);
reactRoot = ReactDOM.createRoot(container);
};
export const renderWithStore = (component) =>
act(() =>
reactRoot.render(
<Provider store={store}>{component}</Provider>
)
);
export const dispatchToStore = (action) =>
act(() => store.dispatch(action));
You’ve now got all the helpers you need to begin testing both sagas and components that are connected to a Redux store. With all that in place, let’s get started on the saga tests.
The saga we’re writing will respond to an ADD_CUSTOMER_REQUEST action that’s dispatched from the CustomerForm component when the user submits the form. The functionality of the saga is just the same as the doSave function listed in the Designing the store state and actions section at the beginning of this chapter. The difference is we’ll need to use the saga’s function calls of put, call, and so forth.
Let’s begin by writing a generator function named addCustomer. Proceed as follows:
import { storeSpy, expectRedux } from "expect-redux";
import { configureStore } from "../../src/store";
describe("addCustomer", () => {
let store;
beforeEach(() => {
store = configureStore([ storeSpy ]);
});
});
const addCustomerRequest = (customer) => ({
type: "ADD_CUSTOMER_REQUEST",
customer,
});
it("sets current status to submitting", () => {
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "ADD_CUSTOMER_SUBMITTING" });
});
Returning promises from tests
This test returns a promise. This is a shortcut we can use instead of marking our test function as async and the expectation with await. Jest knows to wait if the test function returns a promise.
import { put } from "redux-saga/effects";
export function* addCustomer() {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
}
Generator-function syntax
The arrow-function syntax that we’ve been using throughout the book does not work for generator functions, so we need to fall back to using the function keyword.
import {
createStore,
applyMiddleware,
compose,
combineReducers
} from "redux";
import createSagaMiddleware from "redux-saga";
import { takeLatest } from "redux-saga/effects";
import { addCustomer } from "./sagas/customer";
import {
reducer as customerReducer
} from "./sagas/customer";
function* rootSaga() {
yield takeLatest(
"ADD_CUSTOMER_REQUEST",
addCustomer
);
}
export const configureStore = (storeEnhancers = []) => {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
combineReducers({ customer: customerReducer }),
compose(
applyMiddleware(sagaMiddleware),
...storeEnhancers
)
);
sagaMiddleware.run(rootSaga);
return store;
};
That completes the first test for the saga, and gets all the necessary plumbing into place. You’ve also seen how to use put. Next up, let’s introduce call.
Within a saga, call allows us to perform an asynchronous request. Let’s introduce that now. Follow these steps:
it("sends HTTP request to POST /customers", async () => {
const inputCustomer = { firstName: "Ashley" };
store.dispatch(addCustomerRequest(inputCustomer));
expect(global.fetch).toBeCalledWith(
"/customers",
expect.objectContaining({
method: "POST",
})
);
});
beforeEach(() => {
jest.spyOn(global, "fetch");
store = configureStore([ storeSpy ]);
});
import { put, call } from "redux-saga/effects";
const fetch = (url, data) =>
global.fetch(url, {
method: "POST",
});
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
yield call(fetch, "/customers", customer);
}
it("calls fetch with correct configuration", async () => {
const inputCustomer = { firstName: "Ashley" };
store.dispatch(addCustomerRequest(inputCustomer));
expect(global.fetch).toBeCalledWith(
expect.anything(),
expect.objectContaining({
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
})
);
});
const fetch = (url, data) =>
global.fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }
});
it("calls fetch with customer as request body", async () => {
const inputCustomer = { firstName: "Ashley" };
store.dispatch(addCustomerRequest(inputCustomer));
expect(global.fetch).toBeCalledWith(
expect.anything(),
expect.objectContaining({
body: JSON.stringify(inputCustomer),
})
);
});
const fetch = (url, data) =>
global.fetch(url, {
body: JSON.stringify(data),
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }
});
it("dispatches ADD_CUSTOMER_SUCCESSFUL on success", () => {
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer
});
});
const customer = { id: 123 };
beforeEach(() => {
jest
.spyOn(global, "fetch")
.mockReturnValue(fetchResponseOk(customer));
store = configureStore([ storeSpy ]);
});
import { fetchResponseOk } from "../builders/fetch";
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
const result = yield call(fetch, "/customers", customer);
const customerWithId = yield call([result, "json"]);
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId
});
}
it("dispatches ADD_CUSTOMER_FAILED on non-specific error", () => {
global.fetch.mockReturnValue(fetchResponseError());
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "ADD_CUSTOMER_FAILED" });
});
import {
fetchResponseOk,
fetchResponseError
} from "../builders/fetch";
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
const result = yield call(
fetch,
"/customers",
customer
);
if(result.ok) {
const customerWithId = yield call(
[result, "json"]
);
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId
});
} else {
yield put({ type: "ADD_CUSTOMER_FAILED" });
}
}
it("dispatches ADD_CUSTOMER_VALIDATION_FAILED if validation errors were returned", () => {
const errors = {
field: "field",
description: "error text"
};
global.fetch.mockReturnValue(
fetchResponseError(422, { errors })
);
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors: errors
});
});
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
const result = yield call(fetch, "/customers", customer);
if(result.ok) {
const customerWithId = yield call(
[result, "json"]
);
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId
});
} else if (result.status === 422) {
const response = yield call([result, "json"]);
yield put({
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors: response.errors
});
} else {
yield put({ type: "ADD_CUSTOMER_FAILED" });
}
}
The saga is now complete. Compare this function to the function in CustomerForm that we’re replacing: doSave. The structure is identical. That’s a good indicator that we’re ready to work on removing doSave from CustomerForm.
In the next section, we’ll update CustomerForm to make use of our new Redux store.
The saga and reducer are now complete and ready to be used in the CustomerForm React component. In this section, we’ll replace the use of doSave, and then as a final flourish, we’ll push our React Router navigation into the saga, removing the onSave callback from App.
At the start of the chapter, we looked at how the purpose of this change was essentially a transplant of CustomerForm’s doSave function into a Redux action.
With our new Redux setup, we used component state to display a submitting indicator and show any validation errors. That information is now stored within the Redux store, not component state. So, in addition to dispatching an action to replace doSave, the component also needs to read state from the store. The component state variables can be deleted.
This has a knock-on effect on our tests. Since the saga tests the failure modes, our component tests for CustomerForm simply need to handle various states of the Redux store, which we’ll manipulate using our dispatchToStore extension.
We’ll start by making our component Redux-aware, as follows:
import { expectRedux } from "expect-redux";
import {
initializeReactContainer,
renderWithStore,
dispatchToStore,
store,
...
} from "./reactTestExtensions";
it("dispatches ADD_CUSTOMER_REQUEST when submitting data", async () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
await clickAndWait(submitButton());
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: 'ADD_CUSTOMER_REQUEST',
customer: validCustomer
});
});
const handleSubmit = async (event) => {
event.preventDefault();
const validationResult = validateMany(
validators, customer
);
if (!anyErrors(validationResult)) {
await doSave();
dispatch(addCustomerRequest(customer));
} else {
setValidationErrors(validationResult);
}
};
import { useDispatch } from "react-redux";
const dispatch = useDispatch();
const addCustomerRequest = (customer) => ({
type: "ADD_CUSTOMER_REQUEST",
customer,
});
At this point, your component is now Redux-aware, and it’s dispatching the right action to Redux. The remaining work is to modify the component to deal with validation errors coming from Redux rather than the component state.
Now, it’s time to introduce the useSelector hook to pull out state from the store. We’ll kick things off with the ADD_CUSTOMER_FAILED generic error action. Recall that when the reducer receives this, it updates the error store state value to true. Follow these steps:
it("renders error message when error prop is true", () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({ type: "ADD_CUSTOMER_FAILED" });
expect(element("[role=alert]")).toContainText(
"error occurred"
);
});
import {
useDispatch,
useSelector
} from "react-redux";
const {
error,
} = useSelector(({ customer }) => customer);
it("does not submit the form when there are validation errors", async () => {
renderWithStore(
<CustomerForm original={blankCustomer} />
);
await clickAndWait(submitButton());
return expectRedux(store)
.toNotDispatchAnAction(100)
.ofType("ADD_CUSTOMER_REQUEST");
});
The toNotDispatchAnAction matcher
This matcher should always be used with a timeout, such as 100 milliseconds in this case. That’s because, in an asynchronous environment, events may just be slow to occur, rather than not occurring at all.
it("renders field validation errors from server", () => {
const errors = {
phoneNumber: "Phone number already exists in the system"
};
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors: errors
});
expect(
errorFor(phoneNumber)
).toContainText(errors.phoneNumber);
});
So, let’s rename the prop we get back from the server, like so:
const {
error,
validationErrors: serverValidationErrors,
} = useSelector(({ customer }) => customer);
A design issue
This highlights a design issue in our original code. The validationErrors state variable had two uses, which were mixed up. Our change here will separate those uses.
const renderError = fieldName => {
const allValidationErrors = {
...validationErrors,
...serverValidationErrors
};
return (
<span id={`${fieldname}error`} role="alert">
{hasError(allValidationErrors, fieldName)
? allValidationErrors[fieldname]
: ""}
</span>
);
};
it("displays indicator when form is submitting", () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({
type: "ADD_CUSTOMER_SUBMITTING"
});
expect(
element(".submittingIndicator")
).not.toBeNull();
});
const {
error,
status,
validationErrors: serverValidationErrors,
} = useSelector(({ customer }) => customer);
const submitting = status === "SUBMITTING";
it("hides indicator when form has submitted", () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({
type: "ADD_CUSTOMER_SUCCESSFUL"
});
expect(element(".submittingIndicator")).toBeNull();
});
That’s it for test changes, and doSave is almost fully redundant. However, the call to onSave still needs to be migrated across into the Redux saga, which we’ll do in the next section.
Recall that it is the App component that renders CustomerForm, and App passes a function to the CustomerForm’s onSave prop that causes page navigation. When the customer information has been submitted, the user is moved onto the /addAppointment route.
But now that the form submission happens within a Redux saga, how do we call the onSave prop? The answer is that we can’t. Instead, we can move page navigation into the saga itself and delete the onSave prop entirely.
To do this, we must update src/index.js to use HistoryRouter rather than BrowserRouter. That allows you to pass in your own history singleton object, which you can then explicitly construct yourself and then access via the saga. Proceed as follows:
import { createBrowserHistory } from "history";
export const appHistory = createBrowserHistory();
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import {
unstable_HistoryRouter as HistoryRouter
} from "react-router-dom";
import { appHistory } from "./history";
import { configureStore } from "./store";
import { App } from "./App";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={configureStore()}>
<HistoryRouter history={appHistory}>
<App />
</HistoryRouter>
</Provider>
);
import { appHistory } from "../../src/history";
it("navigates to /addAppointment on success", () => {
store.dispatch(addCustomerRequest());
expect(appHistory.location.pathname).toEqual(
"/addAppointment"
);
});
it("includes the customer id in the query string when navigating to /addAppointment", () => {
store.dispatch(addCustomerRequest());
expect(
appHistory.location.search
).toEqual("?customer=123");
});
import { appHistory } from "../history";
export function* addCustomer({ customer }) {
...
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId,
});
appHistory.push(
`/addAppointment?customer=${customerWithId.id}`
);
}
<Route
path="/addCustomer"
element={<CustomerForm original={blankCustomer} />}
/>
You’ve now seen how you can integrate a Redux store into your React components, and how you can control React Router navigation from within a Redux saga.
All being well, your application should now be running with Redux managing the workflow.
This has been a whirlwind tour of Redux and how to refactor your application to it, using TDD.
As warned in the introduction of this chapter, Redux is a complex library that introduces a lot of extra plumbing into your application. Thankfully, the testing approach is straightforward.
In the next chapter, we’ll add yet another library: Relay, the GraphQL client.
For more information, have a look at the following sources:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function*
GraphQL offers an alternative to HTTP requests for fetching data. It offers a whole bunch of additional features that can be added to data requests.
As with Redux, GraphQL systems can seem complicated, but TDD helps to provide an approach to understanding and learning.
In this chapter, we’ll use the Relay library to connect to our backend. We’re going to build a new CustomerHistory component that displays details of a single customer and their appointment history.
This is a bare-bones GraphQL implementation that shows the fundamentals of test-driving the technology. If you’re using other GraphQL libraries instead of Relay, the techniques we’ll explore in this chapter will also apply.
Here’s what the new CustomerHistory component looks like:
Figure 13.1 – The new CustomerHistory component
This chapter covers the following topics:
By the end of the chapter, you’ll have explored the test-driven approach to GraphQL.
The code files for this chapter can be found here:
The code samples for this chapter already contain some additions:
It’s beyond the scope of this book to go into each of these, but you will need to compile the schema before you begin, which can be done by typing the following command:
The npm run build command has also been modified to run this command for you, just in case you forget. Once everything is compiled, you’re ready to write some tests.
There are a few different ways to approach the integration of Relay into a React application. The method we’ll use in this book is the fetchQuery function, which is analogous to the global.fetch function we’ve already used for standard HTTP requests.
However, Relay’s fetchQuery function has a much more complicated setup than global.fetch.
One of the parameters of the fetchQuery function is the environment, and in this section, we’ll see what that is and how to construct it.
Why Do We Need to Construct an Environment?
The Relay environment is an extension point where all manner of functionality can be added. Data caching is one example. If you’re interested in how to do that, check out the Further reading section at the end of this chapter.
We will build a function named buildEnvironment, and then another named getEnvironment that provides a singleton instance of this environment so that the initialization only needs to be done once. Both functions return an object of type Environment.
One of the arguments that the Environment constructor requires is a function named performFetch. This function, unsurprisingly, is the bit that actually fetches data – in our case, from the POST /graphql server endpoint.
In a separate test, we'll check whether performFetch is passed to the new Environment object. We need to treat performFetch as its own unit because we’re not going to be testing the behavior of the resulting environment, only its construction.
Let’s begin by creating our own performFetch function:
import {
fetchResponseOk,
fetchResponseError
} from "./builders/fetch";
import {
performFetch
} from "../src/relayEnvironment";
describe("performFetch", () => {
let response = { data: { id: 123 } };
const text = "test";
const variables = { a: 123 };
beforeEach(() => {
jest
.spyOn(global, "fetch")
.mockResolvedValue(fetchResponseOk(response));
});
});
it("sends HTTP request to POST /graphql", () => {
performFetch({ text }, variables);
expect(global.fetch).toBeCalledWith(
"/graphql",
expect.objectContaining({
method: "POST",
})
);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", {
method: "POST",
});
it("calls fetch with the correct configuration", () => {
performFetch({ text }, variables);
expect(global.fetch).toBeCalledWith(
"/graphql",
expect.objectContaining({
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
})
);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
});
it("calls fetch with query and variables as request body", async () => {
performFetch({ text }, variables);
expect(global.fetch).toBeCalledWith(
"/graphql",
expect.objectContaining({
body: JSON.stringify({
query: text,
variables,
}),
})
);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: operation.text,
variables
})
});
Understanding Operation, Text, and Variables
The text property of the operation argument is a static piece of data that defines the query, and the variables argument will be the piece that is relevant to this specific request.
The tests we’re writing in this chapter do not go as far as checking the behavior of this Relay plumbing code. When writing this type of unit test, which doesn’t exercise behavior, it’s important to note that some kind of end-to-end test will be necessary. That will ensure your unit tests have the right specification.
it("returns the request data", async () => {
const result = await performFetch(
{ text }, variables
);
expect(result).toEqual(response);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", ...)
.then(result => result.json());
it("rejects when the request fails", () => {
global.fetch.mockResolvedValue(
fetchResponseError(500)
);
return expect(
performFetch({ text }, variables)
).rejects.toEqual(new Error(500));
});
const verifyStatusOk = result => {
if (!result.ok) {
return Promise.reject(new Error(500));
} else {
return result;
}
};
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", ...)
.then(verifyStatusOk)
.then(result => result.json());
You’ve now learned how to specify and test the performFetch function required for the Environment constructor. Now, we’re ready to do that construction.
We’re going to build a function named buildEnvironment, that takes all the various pieces we need to build an Environment object. The reason there are so many pieces is that they are all extension points that enable the configuration of the Relay connection.
These pieces are our performFetch function and a bunch of other Relay types that come directly from the relay-runtime package. We’ll use jest.mock to mock all these out in one fell swoop.
Let’s get started:
import {
performFetch,
buildEnvironment
} from "../src/relayEnvironment";
import {
Environment,
Network,
Store,
RecordSource
} from "relay-runtime";
jest.mock("relay-runtime");
describe("buildEnvironment", () => {
const environment = { a: 123 };
beforeEach(() => {
Environment.mockImplementation(() => environment);
});
it("returns environment", () => {
expect(buildEnvironment()).toEqual(environment);
});
});
import {
Environment,
Network,
RecordSource,
Store
} from "relay-runtime";
export const buildEnvironment = () =>
new Environment();
describe("buildEnvironment", () => {
const environment = { a: 123 };
const network = { b: 234 };
const store = { c: 345 };
beforeEach(() => {
Environment.mockImplementation(() => environment);
Network.create.mockReturnValue(network);
Store.mockImplementation(() => store);
});
it("returns environment", () => {
expect(buildEnvironment()).toEqual(environment);
});
it("calls Environment with network and store", () => {
expect(Environment).toBeCalledWith({
network,
store
});
});
});
Mocking Constructors
Note the difference in how we mock out constructors and function calls. To mock out a new Store and a new Environment, we need to use mockImplementation(fn). To mock out Network.create, we need to use mockReturnValue(returnValue).
export const buildEnvironment = () =>
new Environment({
network: Network.create(),
store: new Store()
});
it("calls Network.create with performFetch", () => {
expect(Network.create).toBeCalledWith(performFetch);
});
export const buildEnvironment = () =>
new Environment({
network: Network.create(performFetch),
store: new Store()
});
describe("buildEnvironment", () => {
...
const recordSource = { d: 456 };
beforeEach(() => {
...
RecordSource.mockImplementation(
() => recordSource
);
});
...
});
it("calls Store with RecordSource", () => {
expect(Store).toBeCalledWith(recordSource);
});
export const buildEnvironment = () =>
new Environment({
network: Network.create(performFetch),
store: new Store(new RecordSource())
});
And that, would you believe, is it for buildEnvironment! At this stage, you will have a valid Environment object.
Because creating Environment takes a substantial amount of plumbing, it’s common to construct it once and then use that value for the rest of the application.
An Alternative Approach Using RelayEnvironmentProvider
There is an alternative approach to using the singleton instance shown here, which is to use React Context. The RelayEnvironmentProvider component provided by Relay can help you with that. For more information, see the Further reading section at the end of the chapter.
Let’s build the getEnvironment function:
import {
performFetch,
buildEnvironment,
getEnvironment
} from "../src/relayEnvironment";
describe("getEnvironment", () => {
it("constructs the object only once", () => {
getEnvironment();
getEnvironment();
expect(Environment.mock.calls.length).toEqual(1);
});
});
let environment = null;
export const getEnvironment = () =>
environment || (environment = buildEnvironment());
That’s all for the environment boilerplate. We now have a shiny getEnvironment function that we can use within our React components.
In the next section, we’ll start on the CustomerHistory component.
Now that we have a Relay environment, we can begin to build out our feature. Recall from the introduction that we’re building a new CustomerHistory component that displays customer details and a list of the customer’s appointments. A GraphQL query to return this information already exists in our server, so we just need to call it in the right way. The query looks like this:
customer(id: $id) {
id
firstName
lastName
phoneNumber
appointments {
startsAt
stylist
service
notes
}
}
This says we get a customer record for a given customer ID (specified by the $id parameter), together with a list of their appointments.
Our component will perform this query when it’s mounted. We’ll jump right in with that functionality, by testing the call to fetchQuery:
import React from "react";
import { act } from "react-dom/test-utils";
import {
initializeReactContainer,
render,
renderAndWait,
container,
element,
elements,
textOf,
} from "./reactTestExtensions";
import { fetchQuery } from "relay-runtime";
import {
CustomerHistory,
query
} from "../src/CustomerHistory";
import {
getEnvironment
} from "../src/relayEnvironment";
jest.mock("relay-runtime");
jest.mock("../src/relayEnvironment");
const date = new Date("February 16, 2019");
const appointments = [
{
startsAt: date.setHours(9, 0, 0, 0),
stylist: "Jo",
service: "Cut",
notes: "Note one"
},
{
startsAt: date.setHours(10, 0, 0, 0),
stylist: "Stevie",
service: "Cut & color",
notes: "Note two"
}
];
const customer = {
firstName: "Ashley",
lastName: "Jones",
phoneNumber: "123",
appointments
};
describe("CustomerHistory", () => {
let unsubscribeSpy = jest.fn();
const sendCustomer = ({ next }) => {
act(() => next({ customer }));
return { unsubscribe: unsubscribeSpy };
};
beforeEach(() => {
initializeReactContainer();
fetchQuery.mockReturnValue(
{ subscribe: sendCustomer }
);
});
});
The Return Value of fetchQuery
This function has a relatively complex usage pattern. A call to fetchQuery returns an object with subscribe and unsubscribe function properties We call subscribe with an object with a next callback property. That callback is called by Relay’s fetchQuery each time the query returns a result set. We can use that callback to set our component state. Finally, the unsubscribe function is returned from the useEffect block so that it’s called when the component is unmounted or the relevant props change.
it("calls fetchQuery", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(fetchQuery).toBeCalledWith(
getEnvironment(), query, { id: 123 }
);
});
import React, { useEffect } from "react";
import { fetchQuery, graphql } from "relay-runtime";
import { getEnvironment } from "./relayEnvironment";
export const query = graphql`
query CustomerHistoryQuery($id: ID!) {
customer(id: $id) {
id
firstName
lastName
phoneNumber
appointments {
startsAt
stylist
service
notes
}
}
}
`;
export const CustomerHistory = ({ id }) => {
useEffect(() => {
fetchQuery(getEnvironment(), query, { id });
}, [id]);
return null;
};
Cannot find module './__generated__/CustomerHistoryQuery.graphql' from 'src/CustomerHistory.js'
To fix this, run the following command to compile your GraphQL query:
npx relay-compiler
it("unsubscribes when id changes", async () => {
await renderAndWait(<CustomerHistory id={123} />);
await renderAndWait(<CustomerHistory id={234} />);
expect(unsubscribeSpy).toBeCalled();
});
useEffect(() => {
const subscription = fetchQuery(
getEnvironment(), query, { id }
);
return subscription.unsubscribe;
}, [id]);
it("renders the first name and last name together in a h2", async () => {
await renderAndWait(<CustomerHistory id={123} />);
await new Promise(setTimeout);
expect(element("h2")).toContainText("Ashley Jones");
});
export const CustomerHistory = ({ id }) => {
const [customer, setCustomer] = useState(null);
useEffect(() => {
const subscription = fetchQuery(
getEnvironment(), query, { id }
).subscribe({
next: ({ customer }) => setCustomer(customer),
});
return subscription.unsubscribe;
}, [id]);
const { firstName, lastName } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
</>
);
it("renders the phone number", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(document.body).toContainText("123");
});
const { firstName, lastName, phoneNumber } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
<p>{phoneNumber}</p>
</>
);
it("renders a Booked appointments heading", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(element("h3")).not.toBeNull();
expect(element("h3")).toContainText(
"Booked appointments"
);
});
const { firstName, lastName, phoneNumber } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
<p>{phoneNumber}</p>
<h3>Booked appointments</h3>
</>
);
it("renders a table with four column headings", async () => {
await renderAndWait(<CustomerHistory id={123} />);
const headings = elements(
"table > thead > tr > th"
);
expect(textOf(headings)).toEqual([
"When",
"Stylist",
"Service",
"Notes",
]);
});
const { firstName, lastName, phoneNumber } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
<p>{phoneNumber}</p>
<h3>Booked appointments</h3>
<table>
<thead>
<tr>
<th>When</th>
<th>Stylist</th>
<th>Service</th>
<th>Notes</th>
</tr>
</thead>
</table>
</>
);
const columnValues = (columnNumber) =>
elements("tbody > tr").map(
(tr) => tr.childNodes[columnNumber]
);
it("renders the start time of each appointment in the correct format", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(0))).toEqual([
"Sat Feb 16 2019 09:00",
"Sat Feb 16 2019 10:00",
]);
});
<table>
<thead>
...
</thead>
<tbody>
{customer.appointments.map((appointment, i) => (
<AppointmentRow
appointment={appointment}
key={i}
/>
))}
</tbody>
</table>
const toTimeString = (startsAt) =>
new Date(Number(startsAt))
.toString()
.substring(0, 21);
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
</tr>
);
it("renders the stylist", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(1))).toEqual([
"Jo", "Stevie"
]);
});
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
<td>{appointment.stylist}</td>
</tr>
);
it("renders the service", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(2))).toEqual([
"Cut",
"Cut & color",
]);
});
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
<td>{appointment.stylist}</td>
<td>{appointment.service}</td>
</tr>
);
it("renders notes", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(3))).toEqual([
"Note one",
"Note two",
]);
});
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
<td>{appointment.stylist}</td>
<td>{appointment.service}</td>
<td>{appointment.notes}</td>
</tr>
);
describe("submitting", () => {
const noSend = () => unsubscribeSpy;
beforeEach(() => {
fetchQuery.mockReturnValue({ subscribe: noSend });
});
it("displays a loading message", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(element("[role=alert]")).toContainText(
"Loading"
);
});
});
export const CustomerHistory = ({ id }) => {
const [customer, setCustomer] = useState(null);
useEffect(() => {
...
}, [id]);
if (!customer) {
return <p role="alert">Loading</p>;
}
...
};
describe("when there is an error fetching data", () => {
const errorSend = ({ error }) => {
act(() => error());
return { unsubscribe: unsubscribeSpy };
};
beforeEach(() => {
fetchQuery.mockReturnValue(
{ subscribe: errorSend }
);
});
it("displays an error message", async () => {
await renderAndWait(<CustomerHistory />);
expect(element("[role=alert]")).toContainText(
"Sorry, an error occurred while pulling data from the server."
);
});
});
const [customer, setCustomer] = useState(null);
const [status, setStatus] = useState("loading");
useEffect(() => {
const subscription = fetchQuery(
getEnvironment(), query, { id }
).subscribe({
next: ({ customer }) => {
setCustomer(customer);
setStatus("loaded");
},
error: (_) => setStatus("failed"),
})
return subscription.unsubscribe;
}, [id]);
if (status === "loading") {
return <p role="alert">Loading</p>;
}
if (status === "failed") {
return (
<p role="alert">
Sorry, an error occurred while pulling data from
the server.
</p>
);
}
const { firstName, lastName, phoneNumber } = customer;
...
That completes the new CustomerHistory component. You have now learned how to test-drive the use of Relay’s fetchQuery function in your application, and this component is now ready to integrate with App. This is left as an exercise.
This chapter has explored how to test-drive the integration of a GraphQL endpoint using Relay. You have seen how to test-drive the building of the Relay environment, and how to build a component that uses the fetchQuery API.
In Part 3, Interactivity, we’ll begin work in a new code base that will allow us to explore more complex use cases involving undo/redo, animation, and WebSocket manipulation.
In Chapter 14, Building a Logo Interpreter, we’ll begin by writing new Redux middleware to handle undo/redo behavior.
Integrate the CustomerHistory component into the rest of your application by taking the following steps:
The RelayEnvironmentProvider component:
https://relay.dev/docs/api-reference/relay-environment-provider/
This part introduces a new code base that allows us to explore more complex scenarios where TDD can be applied. You’ll take a deep dive into Redux middleware, animation, and WebSockets. The goal is to show how complex tasks are approached using the TDD workflow.
This part includes the following chapters:
Logo is a programming environment created in the 1960s. It was, for many decades, a popular way to teach children how to code—I have fond memories of writing Logo programs back in high school. At its core, it is a method for building graphics via imperative instructions.
In this part of the book, we’ll build an application called Spec Logo. The starting point is an already-functioning interpreter and a barebones UI. In the following chapters, we’ll bolt on additional features to this codebase.
This chapter provides a second opportunity to test-drive Redux. It covers the following topics:
By the end of the chapter, you’ll have learned how to test-drive complex Redux reducers and middleware.
The code files for this chapter can be found here:
The interface has two panes: the left pane is the drawing pane, which is where the output from the Logo script appears. On the right side is a prompt where the user can edit instructions:
Figure 14.1: The Spec Logo interface
Look at the screenshot. You can see the following:
Although we won’t be writing any Logo code in this chapter, it’s worth spending some time playing around and making your own drawings with the interpreter. Here’s a list of instructions that you can use:
It’s also worth looking through the codebase. The src/parser.js file and the src/language directory contain the Logo interpreter. There are also corresponding test files in the test directory. We won’t be modifying these files, but you may be interested in seeing how this functionality has been tested.
There is a single Redux reducer in src/reducers/script.js. Its defaultState definition neatly encapsulates everything needed to represent the execution of a Logo program. Almost all the app’s React components use this state in some way.
In this chapter, we’ll be adding two more reducers into this directory: one for undo/redo and one for prompt focus. We’ll be making modifications to three React components: MenuButtons, Prompt, and ScriptName.
Let’s start by building a new reducer, named withUndoRedo.
In this section, we’ll add undo and redo buttons at the top of the page, which allow the user to undo and redo statements that they’ve previously run. They’ll work like this:
Aside from adding button elements, the work involved here is building a new reducer, withUndoRedo, which will decorate the script reducer. This reducer will return the same state as the script reducer, but with two additional properties: canUndo and canRedo. In addition, the reducer stores past and future arrays within it that record the past and future states. These will never be returned to the user, just stored, and will replace the current state should the user choose to undo or redo.
The reducer will be a higher-order function that, when called with an existing reducer, returns a new reducer that returns the state we’re expecting. In our production code, we’ll replace this store code:
combineReducers({
script: scriptReducer
})
We’ll replace it with this decorated reducer, which takes exactly the same reducer and wraps it in the withUndoRedo reducer that we’ll build in this section:
combineReducers({
script: withUndoRedo(scriptReducer)
})
To test this, we’ll need to use a spy to act in place of the script reducer, which we’ll call decoratedReducerSpy.
Let’s make a start by building the reducer itself, before adding buttons to exercise the new functionality:
import {
withUndoRedo
} from "../../src/reducers/withUndoRedo";
describe("withUndoRedo", () => {
let decoratedReducerSpy;
let reducer;
beforeEach(() => {
decoratedReducerSpy = jest.fn();
reducer = withUndoRedo(decoratedReducerSpy);
});
describe("when initializing state", () => {
it("calls the decorated reducer with undefined state and an action", () => {
const action = { type: "UNKNOWN" };
reducer(undefined, action);
expect(decoratedReducerSpy).toBeCalledWith(
undefined,
action);
});
});
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
reducer(state, action);
};
};
it("returns a value of what the inner reducer returns", () => {
decoratedReducerSpy.mockReturnValue({ a: 123 });
expect(reducer(undefined)).toMatchObject(
{ a : 123 }
);
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
return reducer(state, action);
};
}
it("cannot undo", () => {
expect(reducer(undefined)).toMatchObject({
canUndo: false
});
});
it("cannot redo", () => {
expect(reducer(undefined)).toMatchObject({
canRedo: false
});
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
return {
canUndo: false,
canRedo: false,
...reducer(state, action)
};
};
}
describe("performing an action", () => {
const innerAction = { type: "INNER" };
const present = { a: 123 };
const future = { b: 234 };
beforeEach(() => {
decoratedReducerSpy.mockReturnValue(future);
});
it("can undo after a new present has been provided", () => {
const result = reducer(
{ canUndo: false, present },
innerAction
);
expect(result.canUndo).toBeTruthy();
});
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
if (state === undefined)
return {
canUndo: false,
canRedo: false,
...reducer(state, action)
};
return {
canUndo: true
};
};
};
it("forwards action to the inner reducer", () => {
reducer(present, innerAction);
expect(decoratedReducerSpy).toBeCalledWith(
present,
innerAction
);
});
if (state === undefined)
...
reducer(state, action);
return {
canUndo: true
};
it("returns the result of the inner reducer", () => {
const result = reducer(present, innerAction);
expect(result).toMatchObject(future);
});
const newPresent = reducer(state, action);
return {
...newPresent,
canUndo: true
};
const present = { a: 123, nextInstructionId: 0 };
const future = { b: 234, nextInstructionId: 1 };
...
it("returns the previous state if nextInstructionId does not increment", () => {
decoratedReducerSpy.mockReturnValue({
nextInstructionId: 0
});
const result = reducer(present, innerAction);
expect(result).toBe(present);
});
const newPresent = reducer(state, action);
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
return {
...newPresent,
canUndo: true
};
}
return state;
This covers all the functionality for performing any actions other than Undo and Redo. The next section covers Undo.
We’ll create a new Redux action, of type UNDO, which causes us to push the current state into a new array called past:
describe("withUndoRedo", () => {
const undoAction = { type: "UNDO" };
const innerAction = { type: "INNER" };
const present = { a: 123, nextInstructionId: 0 };
const future = { b: 234, nextInstructionId: 1 };
...
});
describe("undo", () => {
let newState;
beforeEach(() => {
decoratedReducerSpy.mockReturnValue(future);
newState = reducer(present, innerAction);
});
it("sets present to the latest past entry", () => {
const updated = reducer(newState, undoAction);
expect(updated).toMatchObject(present);
});
});
Performing an action within a beforeEach block
Notice the call to the reducer function in the beforeEach setup. This function is the function under test, so it could be considered part of the Act phase that we usually keep within the test itself. However, in this case, the first call to reducer is part of the test setup, since all these tests rely on having performed at least one action that can then be undone. In this way, we can consider this reducer call to be part of the Assert phase.
export const withUndoRedo = (reducer) => {
let past;
return (state, action) => {
if (state === undefined)
...
switch(action.type) {
case "UNDO":
return past;
default:
const newPresent = reducer(state, action);
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
past = state;
return {
...newPresent,
canUndo: true
};
}
return state;
}
};
};
it("can undo multiple levels", () => {
const futureFuture = {
c: 345, nextInstructionId: 3
};
decoratedReducerSpy.mockReturnValue(futureFuture);
newState = reducer(newState, innerAction);
const updated = reducer(
reducer(newState, undoAction),
undoAction
);
expect(updated).toMatchObject(present);
});
export const withUndoRedo = (reducer) => {
let past = [];
return (state, action) => {
if (state === undefined)
...
switch(action.type) {
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
return lastEntry;
default:
const newPresent = reducer(state, action);
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
past = [ ...past, state ];
return {
...newPresent,
canUndo: true
};
}
return state;
}
};
};
it("sets canRedo to true after undoing", () => {
const updated = reducer(newState, undoAction);
expect(updated.canRedo).toBeTruthy();
});
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
return {
canRedo: true
};
That’s all there is to the UNDO action. Next, let’s add the REDO action.
Redo is very similar to undo, just reversed:
describe("withUndoRedo", () => {
const undoAction = { type: "UNDO" };
const redoAction = { type: "REDO" };
...
});
describe("redo", () => {
let newState;
beforeEach(() => {
decoratedReducerSpy.mockReturnValueOnce(future);
newState = reducer(present, innerAction);
newState = reducer(newState, undoAction);
});
it("sets the present to the latest future entry", () => {
const updated = reducer(newState, redoAction);
expect(updated).toMatchObject(future);
});
});
let past = [], future;
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
future = state;
case "UNDO":
...
case "REDO":
return future;
default:
...
const future = { b: 234, nextInstructionId: 1 };
const futureFuture = { c: 345, nextInstructionId: 3 };
beforeEach(() => {
decoratedReducerSpy.mockReturnValueOnce(future);
decoratedReducerSpy.mockReturnValueOnce(
futureFuture
);
newState = reducer(present, innerAction);
newState = reducer(newState, innerAction);
newState = reducer(newState, undoAction);
newState = reducer(newState, undoAction);
});
it("can redo multiple levels", () => {
const updated = reducer(
reducer(newState, redoAction),
redoAction
);
expect(updated).toMatchObject(futureFuture);
});
let past = [], future = [];
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
future = [ ...future, state ];
case "REDO":
const nextEntry = future[future.length - 1];
future = future.slice(0, -1);
return nextEntry;
it("returns to previous state when followed by an undo", () => {
const updated = reducer(
reducer(newState, redoAction),
undoAction
);
expect(updated).toMatchObject(present);
});
case "REDO":
const nextEntry = future[future.length - 1];
past = [ ...past, state ];
future = future.slice(0, -1);
return nextEntry;
To test for this scenario, you can simulate the sequence and check that you expect to return undefined. This test isn’t great in that we really shouldn’t be sending a REDO action when canRedo returns false, but that’s what our test ends up doing:
it("return undefined when attempting a do, undo, do, redo sequence", () => {
decoratedReducerSpy.mockReturnValue(future);
let newState = reducer(present, innerAction);
newState = reducer(newState, undoAction);
newState = reducer(newState, innerAction);
newState = reducer(newState, redoAction);
expect(newState).not.toBeDefined();
});
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
past = [ ...past, state ];
future = [];
return {
...newPresent,
canUndo: true
};
}
import {
withUndoRedo
} from "./reducers/withUndoRedo";
export const configureStore = (
storeEnhancers = [],
initialState = {}
) => {
return createStore(
combineReducers({
script: withUndoRedo(scriptReducer)
}),
initialState,
compose(...storeEnhancers)
);
};
Your tests should all be passing and the app should still run.
However, the undo and redo functionality is still not accessible. For that, we need to add some buttons to the menu bar.
The final piece to this puzzle is adding buttons to trigger the new behavior by adding Undo and Redo buttons to the menu bar:
describe("undo button", () => {
it("renders", () => {
renderWithStore(<MenuButtons />);
expect(buttonWithLabel("Undo")).not.toBeNull();
});
});
export const MenuButtons = () => {
...
return (
<>
<button>Undo</button>
<button
onClick={() => dispatch(reset())}
disabled={!canReset}
>
Reset
</button>
</>
);
};
it("is disabled if there is no history", () => {
renderWithStore(<MenuButtons />);
expect(
buttonWithLabel("Undo").hasAttribute("disabled")
).toBeTruthy();
});
<button disabled={true}>Undo</button>
it("is enabled if an action occurs", () => {
renderWithStore(<MenuButtons />);
dispatchToStore({
type: "SUBMIT_EDIT_LINE",
text: "forward 10\n"
});
expect(
buttonWithLabel("Undo").hasAttribute("disabled")
).toBeFalsy();
});
export const MenuButtons = () => {
const {
canUndo, nextInstructionId
} = useSelector(({ script }) => script);
...
const canReset = nextInstructionId !== 0;
return (
<>
<button disabled={!canUndo}>Undo</button>
<button
onClick={() => dispatch(reset())}
disabled={!canReset}
>
Reset
</button>
</>
);
}
);
it("dispatches an action of UNDO when clicked", () => {
renderWithStore(<MenuButtons />);
dispatchToStore({
type: "SUBMIT_EDIT_LINE",
text: "forward 10\n"
});
click(buttonWithLabel("Undo"));
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "UNDO" });
});
const reset = () => ({ type: "RESET" });
const undo = () => ({ type: "UNDO" });
export const MenuButtons = () => {
...
return (
<>
<button
onClick={() => dispatch(undo())}
disabled={!canUndo}
>
Undo
</button>
...
</>
);
};
That’s the last change needed. The undo and redo functionality is now complete.
Next up, we’ll move from building a Redux reducer to building Redux middleware.
In this section, we’ll update our app to save the current state to local storage, a persistent data store managed by the user’s web browser. We’ll do that by way of Redux middleware.
Each time a statement is executed in the Spec Logo environment, the entire set of parsed tokens will be saved via the browser’s LocalStorage API. When the user next opens the app, the tokens will be read and replayed through the parser.
The parseTokens function
As a reminder, the parser (in src/parser.js) has a parseTokens function. This is the function we’ll call from within our middleware, and in this section, we’ll build tests to assert that we’ve called this function.
We’ll write a new piece of Redux middleware for the task. The middleware will pull out two pieces of the script state: name and parsedTokens.
Before we begin, let’s review the browser LocalStorage API:
Let’s test-drive our middleware:
import {
save
} from "../../src/middleware/localStorage";
describe("localStorage", () => {
const data = { a: 123 };
let getItemSpy = jest.fn();
let setItemSpy = jest.fn();
beforeEach(() => {
Object.defineProperty(window, "localStorage", {
value: {
getItem: getItemSpy,
setItem: setItemSpy
}});
});
});
describe("save middleware", () => {
const name = "script name";
const parsedTokens = ["forward 10"];
const state = { script: { name, parsedTokens } };
const action = { type: "ANYTHING" };
const store = { getState: () => state };
let next;
beforeEach(() => {
next = jest.fn();
});
const callMiddleware = () =>
save(store)(next)(action);
it("calls next with the action", () => {
callMiddleware();
expect(next).toBeCalledWith(action);
});
});
export const save = store => next => action => {
next(action);
};
it("returns the result of next action", () => {
next.mockReturnValue({ a : 123 });
expect(callMiddleware()).toEqual({ a: 123 });
});
export const save = store => next => action => {
return next(action);
};
it("saves the current state of the store in localStorage", () => {
callMiddleware();
expect(setItemSpy).toBeCalledWith("name", name);
expect(setItemSpy).toBeCalledWith(
"parsedTokens",
JSON.stringify(parsedTokens)
);
});
export const save = store => next => action => {
const result = next(action);
const {
script: { name, parsedTokens }
} = store.getState();
localStorage.setItem("name", name);
localStorage.setItem(
"parsedTokens",
JSON.stringify(parsedTokens)
);
return result;
};
import {
load, save
} from "../../src/middleware/localStorage";
...
describe("load", () => {
describe("with saved data", () => {
beforeEach(() => {
getItemSpy.mockReturnValueOnce("script name");
getItemSpy.mockReturnValueOnce(
JSON.stringify([ { a: 123 } ])
);
});
it("retrieves state from localStorage", () => {
load();
expect(getItemSpy).toBeCalledWith("name");
expect(getItemSpy).toHaveBeenLastCalledWith(
"parsedTokens"
);
});
});
});
export const load = () => {
localStorage.getItem("name");
localStorage.getItem("parsedTokens");
};
describe("load", () => {
let parserSpy;
describe("with saved data", () => {
beforeEach(() => {
parserSpy = jest.fn();
parser.parseTokens = parserSpy;
...
});
it("calls to parsedTokens to retrieve data", () => {
load();
expect(parserSpy).toBeCalledWith(
[ { a: 123 } ],
parser.emptyState
);
});
});
});
import * as parser from "../parser";
export const load = () => {
localStorage.getItem("name");
const parsedTokens = JSON.parse(
localStorage.getItem("parsedTokens")
);
parser.parseTokens(parsedTokens, parser.emptyState);
};
it("returns re-parsed draw commands", () => {
parserSpy.mockReturnValue({ drawCommands: [] });
expect(
load().script
).toHaveProperty("drawCommands", []);
});
export const load = () => {
localStorage.getItem("name");
const parsedTokens = JSON.parse(
localStorage.getItem("parsedTokens")
);
return {
script: parser.parseTokens(
parsedTokens, parser.emptyState
)
};
};
it("returns name", () => {
expect(load().script).toHaveProperty(
"name",
"script name"
);
});
export const load = () => {
const name = localStorage.getItem("name");
const parsedTokens = JSON.parse(
localStorage.getItem("parsedTokens")
);
return {
script: {
...parser.parseTokens(
parsedTokens, parser.initialState
),
name
}
};
};
it("returns undefined if there is no state saved", () => {
getItemSpy.mockReturnValue(null);
expect(load()).not.toBeDefined();
});
if (parsedTokens && parsedTokens !== null) {
return {
...
};
}
...
import {
save, load
} from "./middleware/localStorage";
export const configureStore = (
storeEnhancers = [],
initialState = {}
) => {
return createStore(
combineReducers({
script: withUndoRedo(scriptReducer)
}),
initialState,
compose(
...[
applyMiddleware(save),
...storeEnhancers
]
)
);
};
export const configureStoreWithLocalStorage = () =>
configureStore(undefined, load());
import {
configureStoreWithLocalStorage
} from "./store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={configureStoreWithLocalStorage()}>
<App />
</Provider>
);
That’s it. If you like, this is a great time to run the app for a manual test and try it. Open the browser window, type a few commands, and try it out!
If you’re stuck for commands to run a manual test, you can use these:
forward 100 right 90 to drawSquare repeat 4 [ forward 100 right 90 ] end drawSquare
These commands exercise most of the functionality within the interpreter and display. They’ll come in handy in Chapter 15, Adding Animation, when you’ll want to be manually testing as you make changes.
You’ve learned how to test-drive Redux middleware. For the final part of the chapter, we will write another reducer, this time one that helps us manipulate the browser’s keyboard focus.
The user of our application will, most of the time, be typing in the prompt at the bottom right of the screen. To help them out, we’ll move the keyboard focus to the prompt when the app is launched. We should also do this when another element—such as the name text field or the menu buttons—has been used but has finished its job. Then, the focus should revert back to the prompt, ready for another instruction.
React doesn’t support setting focus, so we need to use a React ref on our components and then drop it into the DOM API.
We’ll do this via a Redux reducer. It will have two actions: PROMPT_FOCUS_REQUEST and PROMPT_HAS_FOCUSED. Any of the React components in our application will be able to dispatch the first action. The Prompt component will listen for it and then dispatch the second, once it has focused.
We’ll start, as ever, with the reducer:
import {
environmentReducer as reducer
} from "../../src/reducers/environment";
describe("environmentReducer", () => {
it("returns default state when existing state is undefined", () => {
expect(reducer(undefined, {})).toEqual({
promptFocusRequest: false
});
});
});
const defaultState = {
promptFocusRequest: false
};
export const environmentReducer = (
state = defaultState,
action) => {
return state;
};
it("sets promptFocusRequest to true when receiving a PROMPT_FOCUS_REQUEST action", () => {
expect(
reducer(
{ promptFocusRequest: false},
{ type: "PROMPT_FOCUS_REQUEST" }
)
).toEqual({
promptFocusRequest: true
});
});
export const environmentReducer = (
state = defaultState,
action
) => {
switch (action.type) {
case "PROMPT_FOCUS_REQUEST":
return { promptFocusRequest: true };
}
return state;
};
it("sets promptFocusRequest to false when receiving a PROMPT_HAS_FOCUSED action", () => {
expect(
reducer(
{ promptFocusRequest: true},
{ type: "PROMPT_HAS_FOCUSED" }
)
).toEqual({
promptFocusRequest: false
});
});
export const environmentReducer = (...) => {
switch (action.type) {
...,
case "PROMPT_HAS_FOCUSED":
return { promptFocusRequest: false };
}
...
}
...
import {
environmentReducer
} from "./reducers/environment";
export const configureStore = (
storeEnhancers = [],
initialState = {}
) => {
return createStore(
combineReducers({
script: withUndoRedo(logoReducer),
environment: environmentReducer
}),
...
);
};
That gives us a new reducer that’s hooked into the Redux store. Now, let’s make use of that.
Let’s move on to the most difficult part of this: focusing the actual prompt. For this, we’ll need to introduce a React ref:
describe("prompt focus", () => {
it("sets focus when component first renders", () => {
renderInTableWithStore(<Prompt />);
expect(
document.activeElement
).toEqual(textArea());
});
});
import
React, { useEffect, useRef, useState }
from "react";
export const Prompt = () => {
...
const inputRef = useRef();
useEffect(() => {
inputRef.current.focus();
}, [inputRef]);
return (
...
<textarea
ref={inputRef}
/>
...
);
};
import {
...,
dispatchToStore,
} from "./reactTestExtensions";
const jsdomClearFocus = () => {
const node = document.createElement("input");
document.body.appendChild(node);
node.focus();
node.remove();
}
it("calls focus on the underlying DOM element if promptFocusRequest is true", async () => {
renderInTableWithStore(<Prompt />);
jsdomClearFocus();
dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" });
expect(document.activeElement).toEqual(textArea());
});
export const Prompt = () => {
const nextInstructionId = ...
const promptFocusRequest = useSelector(
({ environment: { promptFocusRequest } }) =>
promptFocusRequest
);
...
};
useEffect(() => {
inputRef.current.focus();
}, [promptFocusRequest]);
it("dispatches an action notifying that the prompt has focused", () => {
renderWithStore(<Prompt />);
dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" });
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "PROMPT_HAS_FOCUSED" });
});
const submitEditLine = ...
const promptHasFocused = () => (
{ type: "PROMPT_HAS_FOCUSED" }
);
inputRef.current.focus();
dispatch(promptHasFocused());
}, [promptFocusRequest]);
There is a slight issue with this last code snippet. The dispatched PROMPT_HAS_FOCUSED action will set promptFocusRequest back to false. That then causes the useEffect hook to run a second time, with the component re-rendering. This is clearly not intended, nor is it necessary. However, since it has no discernable effect on the user, we can skip fixing it at this time.
This completes the Prompt component, which now steals focus anytime the promptFocusRequest variable changes value.
All that’s left is to call the request action when required. We’ll do this for ScriptName, but you could also do it for the buttons in the menu bar:
it("dispatches a prompt focus request", () => {
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "PROMPT_FOCUS_REQUEST" });
});
const submitScriptName = ...
const promptFocusRequest = () => ({
type: "PROMPT_FOCUS_REQUEST",
});
const completeEditingScriptName = () => {
if (editingScriptName) {
toggleEditingScriptName();
dispatch(submitScriptName(updatedScriptName));
dispatch(promptFocusRequest());
}
};
That’s it! If you build and run now, you’ll see how focus is automatically given to the prompt textbox, and if you edit the script name (by clicking on it, typing something, and then hitting Enter), you’ll see that focus returns to the prompt.
You should now have a good understanding of test-driving complex Redux reducers and middleware.
First, we added support undo/redo with a Redux decorator reducer. Then, we built Redux middleware to save and load existing states via the browser’s LocalStorage API. And finally, we looked at how to test-drive changing the browser’s focus.
In the next chapter, we’ll look at how to test-drive something much more intricate: animation.
Wikipedia entry on the Logo programming language:
Animation lends itself to test-driven development just as much as any other feature. In this chapter, we’ll animate the Logo turtle movement as the user inputs commands.
There are two types of animation in Spec Logo:
Much of this chapter is about test-driving the window.requestAnimationFrame function. This is the browser API that allows us to animate visual elements on the screen, such as the position of the turtle or the length of a line. The mechanics of this function are explained in the third section of this chapter, Animating with requestAnimationFrame.
The importance of manual testing
When writing animation code, it’s natural to want to visually check what we’re building. Automated tests aren’t enough. Manually testing is also important because animation is not something that most programmers do every day. When something is new, it’s often better to do lots of manual tests to verify behavior in addition to your automated tests.
In fact, while preparing for this chapter, I did a lot of manual testing. The walk-through presented here experiments with several different approaches. There were many, many times that I opened my browser to type forward 100 or right 90 to visually verify what was happening.
This chapter covers the following topics:
The code we’ll write is relatively complicated compared to the code in the rest of the book, so we need to do some upfront design first.
By the end of the chapter, you’ll have gained a deep understanding of how to test-drive one of the more complicated browser APIs.
The code files for this chapter can be found here:
As you read through this section, you may wish to open src/Drawing.js and read the existing code to understand what it’s doing.
The current Drawing component shows a static snapshot of how the drawing looks at this point. It renders a set of Scalable Vector Graphics (SVG) lines to represent the path the turtle has taken to this point, and a triangle to represent the turtle.
The component makes use of two child components:
We will add a new AnimatedLine component that represents the current line being animated. As lines complete their animation, they will move into the StaticLines collection.
We’ll need to do some work to convert this from a static view to an animated representation.
As it stands, the component takes a turtle prop and a drawCommands prop. The turtle prop is the current position of the turtle, given that all the draw commands have already been drawn.
In our new animated drawing, we will still treat drawCommands as a list of commands to execute. But rather than relying on a turtle prop to tell us where the turtle is, we’ll store the current position of the turtle as a component state. We will work our way through the drawCommands array, one instruction at a time, and update the turtle component state as it animates. Once all instructions are completed, the turtle component state will match what would have originally been set for the turtle prop.
The turtle always starts at the 0,0 coordinate with an angle of 0.
We will need to keep track of which commands have already been animated. We’ll create another component state variable, animatingCommandIndex, to denote the index of the array item that is currently being animated.
We start animating at the 0 index. Once that command has been animated, we increment the index by 1, moving along to the next command, and animate that. The process is repeated until we reach the end of the array.
This design means that the user can enter new drawCommands at the prompt even if animations are currently running. The component will take care to redraw with animations at the same point it left off at.
Finally, are two types of draw commands: drawLine and rotate. Here are a couple of examples of commands that will appear in the drawCommands array:
{
drawCommand: "drawLine",
id: 123,
x1: 100,
y1: 100,
x2: 200,
y2: 100
}
{
drawCommand: "rotate",
id: 234,
previousAngle: 0,
newAngle: 90
}
Each type of animation will need to be handled differently. So, for example, the AnimatedLine component will be hidden when the turtle is rotating.
That about covers it. We’ll follow this approach:
Let’s get started with the AnimatedLine component.
In this section, we’ll create a new AnimatedLine component.
This component contains no animation logic itself but, instead, draws a line from the start of the line being animated to the current turtle position. Therefore, it needs two props: commandToAnimate, which would be one of the drawLine command structures shown previously, and the turtle prop, containing the position.
Let’s begin:
import React from "react";
import ReactDOM from "react-dom";
import {
initializeReactContainer,
render,
element,
} from "./reactTestExtensions";
import { AnimatedLine } from "../src/AnimatedLine";
import { horizontalLine } from "./sampleInstructions";
const turtle = { x: 10, y: 10, angle: 10 };
describe("AnimatedLine", () => {
beforeEach(() => {
initializeReactContainer();
});
const renderSvg = (component) =>
render(<svg>{component}</svg>);
const line = () => element("line");
});
it("draws a line starting at the x1,y1 co-ordinate of the command being drawn", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={turtle}
/>
);
expect(line()).not.toBeNull();
expect(line().getAttribute("x1")).toEqual(
horizontalLine.x1.toString()
);
expect(line().getAttribute("y1")).toEqual(
horizontalLine.y1.toString()
);
});
import React from "react";
export const AnimatedLine = ({
commandToAnimate: { x1, y1 }
}) => (
<line x1={x1} y1={y1} />
);
it("draws a line ending at the current position of the turtle", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={{ x: 10, y: 20 }}
/>
);
expect(line().getAttribute("x2")).toEqual("10");
expect(line().getAttribute("y2")).toEqual("20");
});
export const AnimatedLine = ({
commandToAnimate: { x1, y1 },
turtle: { x, y }
}) => (
<line x1={x1} y1={y1} x2={x} y2={y} />
);
it("sets a stroke width of 2", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={turtle}
/>
);
expect(
line().getAttribute("stroke-width")
).toEqual("2");
});
it("sets a stroke color of black", () => {
renderSvg(
<AnimatedLine
commandToAnimate={horizontalLine}
turtle={turtle}
/>
);
expect(
line().getAttribute("stroke")
).toEqual("black");
});
export const AnimatedLine = ({
commandToAnimate: { x1, y1 },
turtle: { x, y }
}) => (
<line
x1={x1}
y1={y1}
x2={x}
y2={y}
strokeWidth="2"
stroke="black"
/>
);
That completes the AnimatedLine component.
Next, it’s time to add it into Drawing, by setting the commandToAnimate prop to the current line that’s animating and using requestAnimationFrame to vary the position of the turtle prop.
In this section, you will use the useEffect hook in combination with window.requestAnimationFrame to adjust the positioning of AnimatedLine and Turtle.
The window.requestAnimationFrame function is used to animate visual properties. For example, you can use it to increase the length of a line from 0 units to 200 units over a given time period, such as 2 seconds.
To make this work, you provide it with a callback that will be run at the next repaint interval. This callback is provided with the current animation time when it’s called:
const myCallback = time => {
// animating code here
};
window.requestAnimationFrame(myCallback);
If you know the start time of your animation, you can work out the elapsed animation time and use that to calculate the current value of your animated property.
The browser can invoke your callback at a very high refresh rate, such as 60 times per second. Because of these very small intervals of time, your changes appear as a smooth animation.
Note that the browser only invokes your callback once for every requested frame. That means it’s your responsibility to repeatedly call the requestAnimationFrame function until the animation time reaches your defined end time, as in the following example. The browser takes care of only invoking your callback when the screen is due to be repainted:
let startTime;
let endTimeMs = 2000;
const myCallback = time => {
if (startTime === undefined) startTime = time;
const elapsed = time - startTime;
// ... modify visual state here ...
if (elapsed < endTimeMs) {
window.requestAnimationFrame(myCallback);
}
};
// kick off the first animation frame
window.requestAnimationFrame(myCallback);
As we progress through this section, you’ll see how you can use this to modify the component state (such as the position of AnimatedLine), which then causes your component to rerender.
Let’s begin by getting rid of the existing turtle value from the Redux store—we’re no longer going to use this, and instead, rely on the calculated turtle position from the drawCommands array:
it("initially places the turtle at 0,0 with angle 0", () => {
renderWithStore(<Drawing />);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 0
});
});
const { drawCommands } = useSelector(
({ script }) => script
);
import React, { useState } from "react";
const [turtle, setTurtle] = useState({
x: 0,
y: 0,
angle: 0
});
beforeEach(() => {
...
jest
.spyOn(window, "requestAnimationFrame");
});
describe("movement animation", () => {
const horizontalLineDrawn = {
script: {
drawCommands: [horizontalLine],
turtle: { x: 0, y: 0, angle: 0 },
},
};
it("invokes requestAnimationFrame when the timeout fires", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
expect(window.requestAnimationFrame).toBeCalled();
});
});
import React, { useState, useEffect } from "react";
export const Drawing = () => {
...
useEffect(() => {
requestAnimationFrame();
}, []);
return ...
};
import { act } from "react-dom/test-utils";
import { AnimatedLine } from "../src/AnimatedLine";
jest.mock("../src/AnimatedLine", () => ({
AnimatedLine: jest.fn(
() => <div id="AnimatedLine" />
),
}));
const triggerRequestAnimationFrame = time => {
act(() => {
const mock = window.requestAnimationFrame.mock
const lastCallFirstArg =
mock.calls[mock.calls.length - 1][0]
lastCallFirstArg(time);
});
};
it("renders an AnimatedLine with turtle at the start position when the animation has run for 0s", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(0);
expect(AnimatedLine).toBeRenderedWithProps({
commandToAnimate: horizontalLine,
turtle: { x: 100, y: 100, angle: 0 }
});
});
Using the turtle position for animation
Remember that the AnimatedLine component draws a line from the start position of the drawLine instruction to the current turtle position. That turtle position is then animated, which has the effect of the AnimatedLine instance growing in length until it finds the end position of the drawLine instruction.
const commandToAnimate = drawCommands[0];
const isDrawingLine =
commandToAnimate &&
isDrawLineCommand(commandToAnimate);
useEffect(() => {
const handleDrawLineFrame = time => {
setTurtle(turtle => ({
...turtle,
x: commandToAnimate.x1,
y: commandToAnimate.y1,
}));
};
if (isDrawingLine) {
requestAnimationFrame(handleDrawLineFrame);
}
}, [commandToAnimate, isDrawingLine]);
Using the functional update setter
This code uses the functional update variant of setTurtle that takes a function rather than a value. This is used when the new state value depends on the old value. Using this form of setter means that the turtle doesn’t need to be in the dependency list of useEffect and won’t cause the useEffect hook to reset itself.
import { AnimatedLine } from "./AnimatedLine";
<AnimatedLine
commandToAnimate={commandToAnimate}
turtle={turtle}
/>
it("does not render AnimatedLine when not moving", () => {
renderWithStore(<Drawing />, {
script: { drawCommands: [] }
});
expect(AnimatedLine).not.toBeRendered();
});
{isDrawingLine ? (
<AnimatedLine
commandToAnimate={commandToAnimate}
turtle={turtle}
/> : null}
it("renders an AnimatedLine with turtle at a position based on a speed of 5px per ms", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
expect(AnimatedLine).toBeRenderedWithProps({
commandToAnimate: horizontalLine,
turtle: { x: 150, y: 100, angle: 0 }
});
});
Using animation duration to calculate the distance moved
The handleDrawLineFrame function, when called by the browser, will be passed a time parameter. This is the current duration of the animation. The turtle travels at a constant velocity, so knowing the duration allows us to calculate where the turtle is.
const distance = ({ x1, y1, x2, y2 }) =>
Math.sqrt(
(x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
);
const movementSpeed = 5;
useEffect(() => {
let duration;
const handleDrawLineFrame = time => {
setTurtle(...);
};
if (isDrawingLine) {
duration =
movementSpeed * distance(commandToAnimate);
requestAnimationFrame(handleDrawLineFrame);
}
}, [commandToAnimate, isDrawingLine]);
useEffect(() => {
let duration;
const handleDrawLineFrame = time => {
const { x1, x2, y1, y2 } = commandToAnimate;
setTurtle(turtle => ({
...turtle,
x: x1 + ((x2 - x1) * (time / duration)),
y: y1 + ((y2 - y1) * (time / duration)),
}));
};
if (isDrawingLine) {
...
}
}, [commandToAnimate, isDrawingLine]);
it("calculates move distance with a non-zero animation start time", () => {
const startTime = 12345;
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(startTime);
triggerRequestAnimationFrame(startTime + 250);
expect(AnimatedLine).toBeRenderedWithProps({
commandToAnimate: horizontalLine,
turtle: { x: 150, y: 100, angle: 0 }
});
});
useEffect(() => {
let start, duration;
const handleDrawLineFrame = time => {
if (start === undefined) start = time;
const elapsed = time - start;
const { x1, x2, y1, y2 } = commandToAnimate;
setTurtle(turtle => ({
...turtle,
x: x1 + ((x2 - x1) * (elapsed / duration)),
y: y1 + ((y2 - y1) * (elapsed / duration)),
}));
};
if (isDrawingLine) {
...
}
}, [commandToAnimate, isDrawingLine]);
it("invokes requestAnimationFrame repeatedly until the duration is reached", () => {
renderWithStore(<Drawing />, horizontalLineDrawn);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
triggerRequestAnimationFrame(500);
expect(
window.requestAnimationFrame.mock.calls
).toHaveLength(3);
});
const handleDrawLineFrame = (time) => {
if (start === undefined) start = time;
if (time < start + duration) {
const elapsed = time - start;
const { x1, x2, y1, y2 } = commandToAnimate;
setTurtle(...);
requestAnimationFrame(handleDrawLineFrame);
}
};
describe("after animation", () => {
it("animates the next command", () => {
renderWithStore(<Drawing />, {
script: {
drawCommands: [horizontalLine, verticalLine]
}
});
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
expect(AnimatedLine).toBeRenderedWithProps(
expect.objectContaining({
commandToAnimate: verticalLine,
})
);
});
});
const [
animatingCommandIndex,
setAnimatingCommandIndex
] = useState(0);
const commandToAnimate =
drawCommands[animatingCommandIndex];
if (time < start + duration) {
...
} else {
setAnimatingCommandIndex(
animatingCommandIndex => animatingCommandIndex + 1
);
}
it("places line in StaticLines", () => {
renderWithStore(<Drawing />, {
script: {
drawCommands: [horizontalLine, verticalLine]
}
});
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
expect(StaticLines).toBeRenderedWithProps({
lineCommands: [horizontalLine]
});
});
const lineCommands = drawCommands
.slice(0, animatingCommandIndex)
.filter(isDrawLineCommand);
If you run the app, you’ll now be able to see lines being animated as they are placed on the screen.
In the next section, we’ll ensure the animations behave nicely when multiple commands are entered by the user at the same time.
The useEffect hook we’ve written has commandToAnimate and isDrawingLine in its dependency list. That means that when either of these values updates, the useEffect hook is torn down and will be restarted. But there are other occasions when we want to cancel the animation. One time this happens is when the user resets their screen.
If a command is currently animating when the user clicks the Reset button, we don’t want the current animation frame to continue. We want to clean that up.
Let’s add a test for that now:
it("calls cancelAnimationFrame on reset", () => {
renderWithStore(<Drawing />, {
script: { drawCommands: [horizontalLine] }
});
renderWithStore(<Drawing />, {
script: { drawCommands: [] }
});
expect(window.cancelAnimationFrame).toBeCalledWith(
cancelToken
);
});
describe("Drawing", () => {
const cancelToken = "cancelToken";
beforeEach(() => {
...
jest
.spyOn(window, "requestAnimationFrame")
.mockReturnValue(cancelToken);
jest.spyOn(window, "cancelAnimationFrame");
});
});
useEffect(() => {
let start, duration, cancelToken;
const handleDrawLineFrame = time => {
if (start === undefined) start = time;
if (time < start + duration) {
...
cancelToken = requestAnimationFrame(
handleDrawLineFrame
);
} else {
...
}
};
if (isDrawingLine) {
duration =
movementSpeed * distance(commandToAnimate);
cancelToken = requestAnimationFrame(
handleDrawLineFrame
);
}
return () => {
cancelAnimationFrame(cancelToken);
}
});
it("does not call cancelAnimationFrame if no line animating", () => {
jest.spyOn(window, "cancelAnimationFrame");
renderWithStore(<Drawing />, {
script: { drawCommands: [] }
});
renderWithStore(<React.Fragment />);
expect(
window.cancelAnimationFrame
).not.toHaveBeenCalled();
});
Unmounting a component
This test shows how you can mimic an unmount of a component in React, which is simply by rendering <React.Fragment /> in place of the component under test. React will unmount your component when this occurs.
return () => {
if (cancelToken) {
cancelAnimationFrame(cancelToken);
}
};
That’s all we need to do for animating the drawLine commands. Next up is rotating the turtle.
Our lines and turtle are now animating nicely. However, we still need to handle the second type of draw command: rotations. The turtle will move at a constant speed when rotating to a new angle. A full rotation should take 1 second to complete, and we can use this to calculate the duration of the rotation. For example, a quarter rotation will take 0.25 seconds to complete.
In the last section, we started with a test to check that we were calling requestAnimationFrame. This time, that test isn’t essential because we’ve already proved the same design with drawing lines. We can jump right into the more complex tests, using the same triggerRequestAnimationFrame helper as before.
Let’s update Drawing to animate the turtle’s coordinates:
describe("rotation animation", () => {
const rotationPerformed = {
script: { drawCommands: [rotate90] },
};
it("rotates the turtle", () => {
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 90
});
});
});
const isRotateCommand = command =>
command.drawCommand === "rotate";
const isRotating =
commandToAnimate &&
isRotateCommand(commandToAnimate);
const handleRotationFrame = time => {
setTurtle(turtle => ({
...turtle,
angle: commandToAnimate.newAngle
}));
};
useEffect(() => {
...
if (isDrawingLine) {
...
} else if (isRotating) {
requestAnimationFrame(handleRotationFrame);
}
}, [commandToAnimate, isDrawingLine, isRotating]);
it("rotates part-way at a speed of 1s per 180 degrees", () => {
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 45
});
});
const rotateSpeed = 1000 / 180;
} else if (isRotating) {
duration =
rotateSpeed *
Math.abs(
commandToAnimate.newAngle -
commandToAnimate.previousAngle
);
requestAnimationFrame(handleRotationFrame);
}
const handleRotationFrame = (time) => {
const {
previousAngle, newAngle
} = commandToAnimate;
setTurtle(turtle => ({
...turtle,
angle:
previousAngle +
(newAngle - previousAngle) * (time / duration)
}));
};
it("calculates rotation with a non-zero animation start time", () => {
const startTime = 12345;
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(startTime);
triggerRequestAnimationFrame(startTime + 250);
expect(Turtle).toBeRenderedWithProps({
x: 0,
y: 0,
angle: 45
});
});
const handleRotationFrame = (time) => {
if (start === undefined) start = time;
const elapsed = time - start;
const {
previousAngle, newAngle
} = commandToAnimate;
setTurtle(turtle => ({
...turtle,
angle:
previousAngle +
(newAngle - previousAngle) *
(elapsed / duration)
}));
};
it("invokes requestAnimationFrame repeatedly until the duration is reached", () => {
renderWithStore(<Drawing />, rotationPerformed);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
triggerRequestAnimationFrame(500);
expect(
window.requestAnimationFrame.mock.calls
).toHaveLength(3);
});
const handleRotationFrame = (time) => {
if (start === undefined) start = time;
if (time < start + duration) {
...
} else {
setTurtle(turtle => ({
...turtle,
angle: commandToAnimate.newAngle
}));
}
};
Handling the end animation state
This else clause wasn’t necessary with the drawLine handler because, as soon as a line finishes animating, it will be passed to StaticLines, which renders all lines with their full length. This isn’t the case with the rotation angle: it remains fixed until the next rotation. Therefore, we need to ensure it’s at its correct final value.
it("animates the next command once rotation is complete", async () => {
renderWithStore(<Drawing />, {
script: {
drawCommands: [rotate90, horizontalLine]
}
});
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(500);
triggerRequestAnimationFrame(0);
triggerRequestAnimationFrame(250);
expect(Turtle).toBeRenderedWithProps({
x: 150,
y: 100,
angle: 90
});
});
} else {
setTurtle(turtle => ({
...turtle,
angle: commandToAnimate.newAngle
}));
setAnimatingCommandIndex(
(animatingCommandToIndex) =>
animatingCommandToIndex + 1
);
}
That’s it! If you haven’t done so already, it’s worth running the app to try it out.
In this chapter, we’ve explored how to test the requestAnimationFrame browser API. It’s not a straightforward process, and there are multiple tests that need to be written if you wish to be fully covered.
Nevertheless, you’ve seen that it is entirely possible to write automated tests for onscreen animation. The benefit of doing so is that the complex production code is fully documented via the tests.
In the next chapter, we’ll look at adding WebSocket communication into Spec Logo.
In this chapter, we’ll look at how to test-drive the WebSocket API within our React app. We’ll use it to build a teaching mechanism whereby one person can share their screen and others can watch as they type out commands.
The WebSocket API isn’t straightforward. It uses a number of different callbacks and requires functions to be called in a certain order. To make things harder, we’ll do this all within a Redux saga: that means we’ll need to do some work to convert the callback API to one that can work with generator functions.
Because this is the last chapter covering unit testing techniques, it does things a little differently. It doesn’t follow a strict TDD process. The starting point for this chapter has a skeleton of our functions already completed. You’ll flesh out these functions, concentrating on learning test-driven techniques for WebSocket connections.
This chapter covers the following topics:
By the end of the chapter, you’ll have learned how the WebSocket API works along with its unit testing mechanisms.
The code files for this chapter can be found here:
In this section, we’ll start by describing the sharing workflow, then we’ll look at the new UI elements that support this workflow, and finally we’ll walk through the code changes you’ll make in this chapter.
A sharing session is made up of one presenter and zero or more watchers. That means there are two modes that the app can be in: either presenting or watching.
When the app is in presenting mode, then everyone watching will get a copy of your Spec Logo instructions. All your instructions are sent to the server via a WebSocket.
When your app is in watching mode, a WebSocket receives instructions from the server and immediately outputs them onto your screen.
The messages sent to and from the server are simple JSON-formatted data structures.
Figure 16.1 shows how the interface looks when it’s in presenter mode.
Figure 16.1 – Spec Logo in presenter mode
{ type: "START_SHARING" }
{ status: "STARTED", id: 123 }
http://localhost:3000/index.html?watching=123
{ type: "START_WATCHING", id: 123 }
{
type: "NEW_ACTION",
innerAction: {
type: "SUBMIT_EDIT_LINE",
text: "forward 10\n"
}
}
{ type: "SUBMIT_EDIT_LINE", text: "forward 10\n" } }
Here’s what you’ll find in the UI; all of this has already been built for you:
Next, let’s have a look at the skeleton of the Redux saga that you’ll be fleshing out.
A new piece of Redux middleware exists in the file src/middleware/sharingSagas.js. This file has two parts to it. First, there’s a middleware function named duplicateForSharing. This is a filter that provides us with all the actions that we wish to broadcast:
export const duplicateForSharing =
store => next => action => {
if (action.type === "SUBMIT_EDIT_LINE") {
store.dispatch({
type: "SHARE_NEW_ACTION",
innerAction: action,
});
}
return next(action);
};
Second, there’s the root saga itself. It’s split into four smaller functions, and these are the functions we’ll fill out in this chapter, using a test-driven approach:
export function* sharingSaga() {
yield takeLatest("TRY_START_WATCHING", startWatching);
yield takeLatest("START_SHARING", startSharing);
yield takeLatest("STOP_SHARING", stopSharing);
yield takeLatest("SHARE_NEW_ACTION", shareNewAction);
}
With enough of the design done, let’s get cracking with the implementation.
We start by filling out that first function, startSharing. This function is invoked when the START_SHARING action is received. That action is triggered when the user clicks the Start sharing button:
import { storeSpy, expectRedux } from "expect-redux";
import { act } from "react-dom/test-utils";
import { configureStore } from "../../src/store";
describe("sharingSaga", () => {
let store;
let socketSpyFactory;
beforeEach(() => {
store = configureStore([storeSpy]);
socketSpyFactory = spyOn(window, "WebSocket");
socketSpyFactory.mockImplementation(() => {
return {};
});
});
});
Understanding the WebSocket API
The WebSocket constructor returns an object with send and close methods, plus onopen, onmessage, onclose, and onerror event handlers. We’ll implement most of these on our test double as we build out our test suite. If you’d like to learn more about the WebSocket API, check out the Further reading section at the end of this chapter.
beforeEach(() => {
...
Object.defineProperty(window, "location", {
writable: true,
value: {
protocol: "http:",
host: "test:1234",
pathname: "/index.html",
},
});
});
describe("START_SHARING", () => {
it("opens a websocket when starting to share", () => {
store.dispatch({ type: "START_SHARING" });
expect(socketSpyFactory).toBeCalledWith(
"ws://test:1234/share"
);
});
});
function* startSharing() {
const { host } = window.location;
new WebSocket(`ws://${host}/share`);
}
let sendSpy;
let socketSpy;
beforeEach(() => {
sendSpy = jest.fn();
socketSpyFactory = spyOn(window, "WebSocket");
socketSpyFactory.mockImplementation(() => {
socketSpy = {
send: sendSpy,
};
return socketSpy;
});
...
}
const notifySocketOpened = async () => {
await act(async () => {
socketSpy.onopen();
});
};
Using act with non-React code
The async act function helps us even when we’re not dealing with React components because it waits for promises to run before returning.
it("dispatches a START_SHARING action to the socket", async () => {
store.dispatch({ type: "START_SHARING" });
await notifySocketOpened();
expect(sendSpy).toBeCalledWith(
JSON.stringify({ type: "START_SHARING" })
);
});
const openWebSocket = () => {
const { host } = window.location;
const socket = new WebSocket(`ws://${host}/share`);
return new Promise(resolve => {
socket.onopen = () => {
resolve(socket)
};
});
};
function* startSharing() {
const presenterSocket = yield openWebSocket();
presenterSocket.send(
JSON.stringify({ type: "START_SHARING" })
);
}
const sendSocketMessage = async message => {
await act(async () => {
socketSpy.onmessage({
data: JSON.stringify(message)
});
});
};
it("dispatches an action of STARTED_SHARING with a URL containing the id that is returned from the server", async () => {
store.dispatch({ type: "START_SHARING" });
await notifySocketOpened();
await sendSocketMessage({
type: "UNKNOWN",
id: 123,
});
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: "STARTED_SHARING",
url: "http://test:1234/index.html?watching=123",
});
});
const receiveMessage = (socket) =>
new Promise(resolve => {
socket.onmessage = evt => {
resolve(evt.data)
};
});
const buildUrl = (id) => {
const {
protocol, host, pathname
} = window.location;
return (
`${protocol}//${host}${pathname}?watching=${id}`
);
};
function* startSharing() {
const presenterSocket = yield openWebSocket();
presenterSocket.send(
JSON.stringify({ type: "START_SHARING" })
);
const message = yield receiveMessage(
presenterSocket
);
const presenterSessionId = JSON.parse(message).id;
yield put({
type: "STARTED_SHARING",
url: buildUrl(presenterSessionId),
});
}
That’s it for the process of starting to share. Now let’s deal with what happens when the user clicks the Stop sharing button:
const startSharing = async () => {
store.dispatch({ type: "START_SHARING" });
await notifySocketOpened();
await sendSocketMessage({
type: "UNKNOWN",
id: 123,
});
};
let closeSpy;
beforeEach(() => {
sendSpy = jest.fn();
closeSpy = jest.fn();
socketSpyFactory = spyOn(window, "WebSocket");
socketSpyFactory.mockImplementation(() => {
socketSpy = {
send: sendSpy,
close: closeSpy,
};
return socketSpy;
});
...
});
describe("STOP_SHARING", () => {
it("calls close on the open socket", async () => {
await startSharing();
store.dispatch({ type: "STOP_SHARING" });
expect(closeSpy).toBeCalled();
});
});
let presenterSocket;
function* startSharing() {
presenterSocket = yield openWebSocket();
...
}
function* stopSharing() {
presenterSocket.close();
}
Running tests in just a single suite
To avoid seeing the console errors, remember you can opt to run tests for this test suite only using the command npm test test/middleware/sharingSagas.test.js.
it("dispatches an action of STOPPED_SHARING", async () => {
await startSharing();
store.dispatch({ type: "STOP_SHARING" });
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STOPPED_SHARING" });
});
function* stopSharing() {
presenterSocket.close();
yield put({ type: "STOPPED_SHARING" });
}
Next up is broadcasting actions from the presenter to the server:
describe("SHARE_NEW_ACTION", () => {
it("forwards the same action on to the socket", async () => {
const innerAction = { a: 123 };
await startSharing(123);
store.dispatch({
type: "SHARE_NEW_ACTION",
innerAction,
});
expect(sendSpy).toHaveBeenLastCalledWith(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
});
});
const shareNewAction = ({ innerAction }) => {
presenterSocket.send(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
}
it("does not forward if the socket is not set yet", () => {
store.dispatch({ type: "SHARE_NEW_ACTION" });
expect(sendSpy).not.toBeCalled();
});
Using not.toBeCalled in an asynchronous environment
This test has a subtle issue. Although it will help you add to the design of your software, it’s slightly less useful as a regression test because it could potentially result in false positives. This test guarantees that something doesn’t happen between the start and the end of the test, but it makes no guarantees about what happens after. Such is the nature of the async environment.
function* shareNewAction({ innerAction } ) {
if (presenterSocket) {
presenterSocket.send(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
}
}
it("does not forward if the socket has been closed", async () => {
await startSharing();
socketSpy.readyState = WebSocket.CLOSED;
store.dispatch({ type: "SHARE_NEW_ACTION" });
expect(sendSpy.mock.calls).toHaveLength(1);
});
The WebSocket specification
The constant in the preceding test, WebSocket.CLOSED, and the constant in the following code, WebSocket.OPEN, are defined in the WebSocket specification.
const WEB_SOCKET_OPEN = WebSocket.OPEN;
const WEB_SOCKET_CLOSED = WebSocket.CLOSED;
socketSpyFactory = jest.spyOn(window, "WebSocket");
Object.defineProperty(socketSpyFactory, "OPEN", {
value: WEB_SOCKET_OPEN
});
Object.defineProperty(socketSpyFactory, "CLOSED", {
value: WEB_SOCKET_CLOSED
});
socketSpyFactory.mockImplementation(() => {
socketSpy = {
send: sendSpy,
close: closeSpy,
readyState: WebSocket.OPEN,
};
return socketSpy;
});
const shareNewAction = ({ innerAction }) => {
if (
presenterSocket &&
presenterSocket.readyState === WebSocket.OPEN
) {
presenterSocket.send(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
}
}
That’s it for the presenter behavior: we have test-driven the onopen, onclose, and onmessage callbacks. In a real-world application, you would want to follow the same process for the onerror callback.
Now let’s look at the watcher’s behavior.
We’ll repeat a lot of the same techniques in this section. There are two new concepts: first, pulling out the search param for the watcher ID, and second, using eventChannel to subscribe to the onmessage callback. This is used to continually stream messages from the WebSocket into the Redux store.
Let’s being by specifying the new URL behavior:
describe("watching", () => {
beforeEach(() => {
Object.defineProperty(window, "location", {
writable: true,
value: {
host: "test:1234",
pathname: "/index.html",
search: "?watching=234"
}
});
});
it("opens a socket when the page loads", () => {
store.dispatch({ type: "TRY_START_WATCHING" });
expect(socketSpyFactory).toBeCalledWith(
"ws://test:1234/share"
);
});
});
function* startWatching() {
yield openWebSocket();
}
it("does not open socket if the watching field is not set", () => {
window.location.search = "?";
store.dispatch({ type: "TRY_START_WATCHING" });
expect(socketSpyFactory).not.toBeCalled();
});
function* startWatching() {
const sessionId = new URLSearchParams(
window.location.search.substring(1)
).get("watching");
if (sessionId) {
yield openWebSocket();
}
}
const startWatching = async () => {
await act(async () => {
store.dispatch({ type: "TRY_START_WATCHING" });
socketSpy.onopen();
});
};
it("dispatches a RESET action", async () => {
await startWatching();
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "RESET" });
});
function* startWatching() {
const sessionId = new URLSearchParams(
location.search.substring(1)
).get("watching");
if (sessionId) {
yield openWebSocket();
yield put({ type: "RESET" });
}
}
it("sends the session id to the socket with an action type of START_WATCHING", async () => {
await startWatching();
expect(sendSpy).toBeCalledWith(
JSON.stringify({
type: "START_WATCHING",
id: "234",
})
);
});
function* startWatching() {
const sessionId = new URLSearchParams(
window.location.search.substring(1)
).get("watching");
if (sessionId) {
const watcherSocket = yield openWebSocket();
yield put({ type: "RESET" });
watcherSocket.send(
JSON.stringify({
type: "START_WATCHING",
id: sessionId,
})
);
}
}
it("dispatches a STARTED_WATCHING action", async () => {
await startWatching();
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STARTED_WATCHING" });
});
function* startWatching() {
...
if (sessionId) {
...
yield put({ type: "STARTED_WATCHING" });
}
}
it("relays multiple actions from the websocket", async () => {
const message1 = { type: "ABC" };
const message2 = { type: "BCD" };
const message3 = { type: "CDE" };
await startWatching();
await sendSocketMessage(message1);
await sendSocketMessage(message2);
await sendSocketMessage(message3);
await expectRedux(store)
.toDispatchAnAction()
.matching(message1);
await expectRedux(store)
.toDispatchAnAction()
.matching(message2);
await expectRedux(store)
.toDispatchAnAction()
.matching(message3);
socketSpy.onclose();
});
Long tests
You may think it would help to have a smaller test that handles just one message. However, that won’t help us for multiple messages, as we need to use an entirely different implementation for multiple messages, as you’ll see in the next step.
import { eventChannel, END } from "redux-saga";
const webSocketListener = socket =>
eventChannel(emitter => {
socket.onmessage = emitter;
socket.onclose = () => emitter(END);
return () => {
socket.onmessage = undefined;
socket.onclose = undefined;
};
});
Understanding the eventChannel function
The eventChannel function from redux-saga is a mechanism for consuming event streams that occur outside of Redux. In the preceding example, the WebSocket provides the stream of events. When invoked, eventChannel calls the provided function to initialize the channel, then the provided emmitter function must be called each time an event is received. In our case, we pass the message directly to the emmitter function without modification. When the WebSocket is closed, we pass the special END event to signal to redux-saga that no more events will be received, allowing it to close the channel.
function* watchUntilStopRequest(chan) {
try {
while (true) {
let evt = yield take(chan);
yield put(JSON.parse(evt.data));
}
} finally {
}
};
function* startWatching() {
...
if (sessionId) {
...
yield put({ type: "STARTED_WATCHING" });
const channel = yield call(
webSocketListener, watcherSocket
);
yield call(watchUntilStopRequest(channel);
}
}
it("dispatches a STOPPED_WATCHING action when the connection is closed", async () => {
await startWatching();
socketSpy.onclose();
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STOPPED_WATCHING" });
});
try {
...
} finally {
yield put({ type: "STOPPED_WATCHING" });
}
You’ve now completed the saga: your application is now receiving events, and you’ve seen how to use the eventChannel function to listen to a stream of messages.
All that’s left is to integrate this into our React component.
We’ve completed the work on building the sagas, but we have just a couple of adjustments to make in the rest of the app.
The MenuButtons component is already functionally complete, but we need to update the tests to properly exercise the middleware, in two ways: first, we must stub out the WebSocket constructor, and second, we need to fire off a TRY_START_WATCHING action as soon as the app starts:
import { act } from "react-dom/test-utils";
describe("sharing button", () => {
let socketSpyFactory;
let socketSpy;
beforeEach(() => {
socketSpyFactory = jest.spyOn(
window,
"WebSocket"
);
socketSpyFactory.mockImplementation(() => {
socketSpy = {
close: () => {},
send: () => {},
};
return socketSpy;
});
});
});
const notifySocketOpened = async () => {
const data = JSON.stringify({ id: 1 });
await act(async () => {
socketSpy.onopen();
socketSpy.onmessage({ data });
});
};
it("dispatches an action of STOP_SHARING when stop sharing is clicked", async () => {
renderWithStore(<MenuButtons />);
dispatchToStore({ type: "START_SHARING" });
await notifySocketOpened();
click(buttonWithLabel("Stop sharing"));
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STOP_SHARING" });
});
const store = configureStoreWithLocalStorage();
store.dispatch({ type: "TRY_START_WATCHING" });
ReactDOM
.createRoot(document.getElementById("root"))
.render(
<Provider store={store}>
<App />
</Provider);
You can now run the app and try it out. Here’s a manual test you can try:
That covers test-driving WebSockets.
In this chapter, we’ve covered how to test against the WebSocket API.
You’ve seen how to mock the WebSocket constructor function, and how to test-drive its onopen, onclose, and onmessage callbacks.
You’ve also seen how to use a Promise object to convert a callback into something that can be yielded in a generator function, and how you can use eventChannel to take a stream of events and send them into the Redux store.
In the next chapter, we’ll look at using Cucumber tests to drive some improvements to the sharing feature.
What tests could you add to ensure that socket errors are handled gracefully?
The WebSocket specification:
This part is about behavior-driven development (BDD) using Cucumber tests. Whereas the first three parts were focused on building Jest unit tests at the component level, this part looks at writing tests at the system level—you might also think of these as end-to-end tests. The goal is to show how the TDD workflow applies beyond unit testing and can be used by the whole team, not just developers.
Finally, we end the book with a discussion of how TDD fits within the wider testing landscape and suggestions for how you can continue your TDD journey.
This part includes the following chapters:
Test-driven development is primarily a process for developers. Sometimes, customers and product owners want to see the results of automated tests too. Unfortunately, the humble unit test that is the foundation of TDD is simply too low-level to be helpful to non-developers. That’s where the idea of Behavior Driven Development (BDD) comes in.
BDD tests have a few characteristics that set them apart from the unit tests you’ve seen so far:
BDD tools vs TDD vs unit tests
The style of TDD you’ve seen so far in this book treats (for the most part) its tests as examples that specify behavior. Also, our tests were always written in the Arrange-Act-Assert (AAA) pattern. However, notice that unit test tools such as Jest do not force you to write tests this way.
This is one reason why BDD tools exist: to force you to be very clear when you specify the behavior of your system.
This chapter introduces two new software packages: Cucumber and Puppeteer.
We’ll use Cucumber to build our BDD tests. Cucumber is a system that exists for many different programming environments, including Node.js. It consists of a test runner that runs tests contained within feature files. Features are written in a plain-English language known as Gherkin. When Cucumber runs your tests, it translates these feature files into function calls; these function calls are written in JavaScript support scripts.
Since Cucumber has its own test runner, it doesn’t use Jest. However, we will make use of Jest’s expect package in some of our tests.
Cucumber is not the only way to write system tests
Another popular testing library is Cypress, which may be a better choice for you and/or your team. Cypress puts the emphasis on the visual presentation of results. I tend to avoid it because its API is very different from industry-standard testing patterns, which increases the amount of knowledge developers need to have. Cucumber is cross-platform and tests look very similar to the standard unit tests you’ve seen throughout this book.
Puppeteer performs a similar function to the JSDOM library. However, while JSDOM implements a fake DOM API within the Node.js environment, Puppeteer uses a real web browser, Chromium. In this book, we’ll use it in headless mode, which means you won’t see the app running onscreen; but you can, if you wish, turn headless mode off. Puppeteer comes with all sorts of bolt-ons, such as the ability to take screenshots.
Cross-browser testing
If you wish to test cross-browser support for your application, you may be better off looking at an alternative such as Selenium, which isn’t covered in this book. However, the same testing principles apply when writing tests for Selenium.
This chapter covers the following topics:
By the end of the chapter, you’ll have a good idea of how a Cucumber test is built and run.
The code files for this chapter can be found here:
Let’s add the necessary packages to our project:
$ npm install --save-dev @cucumber/cucumber puppeteer
$ npm install --save-dev @babel/register
{
"default": {
"publishQuiet": true,
"requireModule": [
"@babel/register"
]
}
}
You can now run tests with the following command:
$ npx cucumber-js
You’ll see output like this:
0 scenarios
0 steps
0m00.000s
Throughout this chapter and the following one, it may be helpful to narrow down the tests you’re running. You can run a single scenario by providing the test runner with the filename and starting line number of the scenario:
$ npx cucumber-js features/drawing.feature:5
That’s all there is to getting set up with Cucumber and Puppeteer—now it’s time to write a test.
In this section, you’ll build a Cucumber feature file for a part of the Spec Logo application that we’ve already built.
Warning on Gherkin code samples
If you’re reading an electronic version of this book, be careful when copying and pasting feature definitions. You may find extra line breaks are inserted into your code that Cucumber won’t recognise. Before running your tests, please look through your pasted code snippets and remove any line breaks that shouldn’t be there.
Let’s get started!
Use package.json scripts to your advantage
You could also modify your package.json scripts to invoke a build before Cucumber specs are run, or to run webpack in watch mode.
Feature: Sharing
A user can choose to present their session to any
number of other users, who observe what the
presenter is doing via their own browser.
Scenario: Observer joins a session
Given the presenter navigated to the application page
And the presenter clicked the button 'startSharing'
When the observer navigates to the presenter's sharing link
Then the observer should see a message saying 'You are now watching the session'
Gherkin syntax
Given, When, and Then are analogous to the Arrange, Act, and Assert phases of your Jest tests: given all these things are true, when I perform this action, then I expect all these things to happen.
Ideally, you’d have a single When clause in each of your scenarios.
You’ll notice that I’ve written the Given clauses in past tense and the When clause in the present tense, and the Then clause has a “should” in there.
? Given the presenter navigated to the application page
Undefined. Implement with the following snippet:
Given('the presenter navigated to the application page', function () {
// Write code here that turns the phrase above
// into concrete actions
return 'pending';
});
import {
Given, When, Then
} from "@cucumber/cucumber";
import puppeteer from "puppeteer";
const port = process.env.PORT || 3000;
const appPage = `http://localhost:${port}/index.html`;
Given(
"the presenter navigated to the application page",
async function () {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(appPage);
}
);
Anonymous functions, not lambda expressions
You may be wondering why we are defining anonymous functions (async function (...) { ... }) rather than lambda expressions (async (...) => { ... }). It allows us to take advantage of the implicit context binding that occurs with anonymous functions. If we used lambdas, we’d need to call .bind(this) on them.
import {
setWorldConstructor
} from "@cucumber/cucumber";
class World {
constructor() {
this.pages = {};
}
setPage(name, page) {
this.pages[name] = page;
}
getPage(name) {
return this.pages[name];
}
};
setWorldConstructor(World);
Given(
"the presenter navigated to the application page",
async function () {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(appPage);
this.setPage("presenter", page);
}
);
Given(
"the presenter clicked the button {string}",
async function (buttonId) {
await this.getPage(
"presenter"
).click(`button#${buttonId}`);
}
);
When(
"the observer navigates to the presenter's sharing link",
async function () {
await this.getPage(
"presenter"
).waitForSelector("a");
const link = await this.getPage(
"presenter"
).$eval("a", a => a.getAttribute("href"));
const url = new URL(link);
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
this.setPage("observer", page);
}
);
Step definition duplication
There’s some duplication building up between our step definitions. Later on, we’ll extract this commonality into its own function.
import expect from "expect";
...
Then(
"the observer should see a message saying {string}",
async function (message) {
const pageText = await this.getPage(
"observer"
).$eval("body", e => e.outerHTML);
expect(pageText).toContain(message);
}
);
1) Scenario: Observer joins a session
✖ Given the presenter navigated to the application page
Error: net::ERR_CONNECTION_REFUSED at http://localhost:3000/index.html
Starting a server from within the same project
We are lucky that all our code lives within the same project, so it can be started within the same process. If your code base is split over multiple projects, you may find yourself dealing with multiple processes.
import { app } from "../../server/src/app";
class World {
...
startServer() {
const port = process.env.PORT || 3000;
this.server = app.listen(port);
}
closeServer() {
Object.keys(this.pages).forEach(name =>
this.pages[name].browser().close()
);
this.server.close();
}
}
import { Before, After } from "@cucumber/cucumber";
Before(function() {
this.startServer();
});
After(function() {
this.closeServer();
});
> npx cucumber-js
......
1 scenario (1 passed)
4 steps (4 passed)
0m00.848s
async browseToPageFor(role, url) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
this.setPage(role, page);
}
import puppeteer from "puppeteer";
Given(
"the presenter navigated to the application page",
async function () {
await this.browseToPageFor("presenter", appPage);
}
);
When(
"the observer navigates to the presenter's sharing link",
async function () {
await this.getPage(
"presenter"
).waitForSelector("a");
const link = await this.getPage(
"presenter"
).$eval("a", a => a.getAttribute("href"));
const url = new URL(link);
await this.browseToPageFor("observer", url);
}
);
Observing within a browser and with console logging
The tests we’ve written run Puppeteer in headless mode, meaning that an actual Chrome browser window doesn’t launch. If you’d like to see that happen, you can turn headless mode off by modifying the launch commands (remember there are two in the previous step definitions) to read as follows:
const browser = await puppeteer.launch(
{ headless: false }
);
If you’re using console logging to assist in your debugging, you’ll need to provide another parameter to dump console output to stdout:
const browser = await puppeteer.launch(
{ dumpio: true }
);
You’ve now written a BDD test with Cucumber and Puppeteer. Next, let’s look at a more advanced Cucumber scenario.
In this section, we’ll look at a useful time-saving feature of Cucumber: data tables. We’ll write a second scenario that, as with the previous one, will already pass given the existing implementation of Spec Logo:
Feature: Drawing
A user can draw shapes by entering commands
at the prompt.
Scenario: Drawing functions
Given the user navigated to the application page
When the user enters the following instructions at the prompt:
| to drawsquare |
| repeat 4 [ forward 10 right 90 ] |
| end |
| drawsquare |
Then these lines should have been drawn:
| x1 | y1 | x2 | y2 |
| 0 | 0 | 10 | 0 |
| 10 | 0 | 10 | 10 |
| 10 | 10 | 0 | 10 |
| 0 | 10 | 0 | 0 |
const port = process.env.PORT || 3000;
appPage() {
return `http://localhost:${port}/index.html`;
}
Given(
"the presenter navigated to the application page",
async function () {
await this.browseToPageFor(
"presenter",
this.appPage()
);
}
);
import {
Given,
When,
Then
} from "@cucumber/cucumber";
import expect from "expect";
Given("the user navigated to the application page",
async function () {
await this.browseToPageFor(
"user",
this.appPage()
);
}
);
1) Scenario: Drawing functions
✔ Before # features/support/sharing.steps.js:5
✔ Given the user navigated to the application page
? When the user enters the following instructions at the prompt:
| to drawsquare |
| repeat 4 [ forward 10 right 90 ] |
| end |
| drawsquare |
Undefined. Implement with the following snippet:
When('the user enters the following instructions at the prompt:',
function (dataTable) {
// Write code here that turns the phrase above
// into concrete actions
return 'pending';
}
);
When(
"the user enters the following instructions at the prompt:",
function (dataTable) {
// Write code here that turns the phrase above
//into concrete actions
return "pending";
}
);
When(
"the user enters the following instructions at the prompt:",
async function (dataTable) {
for (let instruction of dataTable.raw()) {
await this.getPage("user").type(
"textarea",
`${instruction}\n`
);
}
}
);
Then("these lines should have been drawn:",
async function(dataTable) {
await this.getPage("user").waitForTimeout(2000);
const lines = await this.getPage("user").$$eval(
"line",
lines => lines.map(line => {
return {
x1: parseFloat(line.getAttribute("x1")),
y1: parseFloat(line.getAttribute("y1")),
x2: parseFloat(line.getAttribute("x2")),
y2: parseFloat(line.getAttribute("y2"))
};
})
);
for (let i = 0; i < lines.length; ++i) {
expect(lines[i].x1).toBeCloseTo(
parseInt(dataTable.hashes()[i].x1)
);
expect(lines[i].y1).toBeCloseTo(
parseInt(dataTable.hashes()[i].y1)
);
expect(lines[i].x2).toBeCloseTo(
parseInt(dataTable.hashes()[i].x2)
);
expect(lines[i].y2).toBeCloseTo(
parseInt(dataTable.hashes()[i].y2)
);
}
}
});
That last test contained some complexity that’s worth diving into:
Go ahead and run your tests again with npx cucumber-js. Everything should be passing.
You’ve now got a good understanding of using Cucumber data tables to make more compelling BDD tests.
Cucumber tests (and BDD tests in general) are similar to the unit tests we’ve been writing in the rest of the book. They are focused on specifying examples of behavior. They should make use of real data and numbers as means to test a general concept, like we’ve done in the two examples in this chapter.
BDD tests differ from unit tests in that they are system tests (having a much broader test surface area) and they are written in natural language.
Just as with unit tests, it’s important to find ways to simplify the code when writing BDD tests. The number one rule is to try to write generic Given, When, and Then phrases that can be reused across classes and extracted out of step definition files, either into the World class or some other module. We’ve seen an example of how to do that in this chapter.
In the next chapter, we’ll use a BDD test to drive the implementation of a new feature in Spec Logo.
In the last chapter, we studied the basic elements of writing Cucumber tests and how to use Puppeteer to manipulate our UI. But we haven’t yet explored how these techniques fit into the wider development process. In this chapter, we’ll implement a new application feature, but starting the process with Cucumber tests. These will act as acceptance tests that our (imaginary) product owner can use to determine whether the software works as required.
Acceptance testing
An acceptance test is a test that a product owner or customer can use to decide whether they accept the delivered software. If it passes, they accept the software. If it fails, the developers must go back and adjust their work.
We can use the term Acceptance-Test-Driven Development (ATDD) to refer to a testing workflow that the whole team can participate in. Think of it as like TDD but it is done at the wider team level, with the product owner and customer involved in the cycle. Writing BDD tests using Cucumber is one way—but not the only way—that you can bring ATDD to your team.
In this chapter, we’ll use our BDD-style Cucumber tests to act as our acceptance tests.
Imagine that our product owner has seen the great work that we’ve done building Spec Logo. They have noted that the share screen functionality is good, but it could do with an addition: it should give the presenter the option of resetting their state before sharing begins, as shown:
Figure 18.1 – The new sharing dialog
The product owner has provided us with some Cucumber tests that are currently red for implementation—both the step definitions and the production code.
This chapter covers the following topics:
By the end of the chapter, you’ll have seen more examples of Cucumber tests and how they can be used as part of your team’s workflow. You’ll also have seen how to avoid using specific timeouts within your code.
The code files for this chapter can be found here:
In this section, we’ll add a new Cucumber test that won’t yet pass.
Let’s start by taking a look at the new feature:
Scenario: Presenter chooses to reset current state when sharing
Given the presenter navigated to the application page
And the presenter entered the following instructions at the prompt:
| forward 10 |
| right 90 |
And the presenter clicked the button 'startSharing'
When the presenter clicks the button 'reset'
And the observer navigates to the presenter's sharing link
Then the observer should see no lines
And the presenter should see no lines
And the observer should see the turtle at x = 0, y = 0, angle = 0
And the presenter should see the turtle at x = 0, y = 0, angle = 0
When(
"the presenter entered the following instructions at the prompt:",
async function(dataTable) {
for (let instruction of dataTable.raw()) {
await this.getPage("presenter").type(
"textarea",
`${instruction}\n`
);
await this.getPage(
"presenter"
).waitForTimeout(3500);
}
}
);
When(
"the presenter clicks the button {string}",
function (string) {
// Write code here that turns the phrase above
// into concrete actions
return "pending";
}
);
Two When phrases
This scenario has two When phrases, which is unusual. Just as with your unit tests in the Act phase, you generally want just one When phrase. However, since there are two users working together at this point, it makes sense to have a single action for both of them, so we’ll let our product owner off the hook on this occasion.
When(
"the presenter clicks the button {string}",
async function (
buttonId
) {
await this.getPage(
"presenter"
).waitForSelector(`button#${buttonId}`);
await this.getPage(
"presenter"
).click(`button#${buttonId}`);
}
);
Then("the observer should see no lines", function () {
// Write code here that turns the phrase above
// into concrete actions
return "pending";
});
Then(
"the observer should see no lines",
async function () {
const numLines = await this.getPage(
"observer"
).$$eval("line", lines => lines.length);
expect(numLines).toEqual(0);
}
);
Then(
"the presenter should see no lines",
async function () {
const numLines = await this.getPage(
"presenter"
).$$eval("line", lines => lines.length);
expect(numLines).toEqual(0);
}
);
✖ And the presenter should see no lines
Error: expect(received).toEqual(expected)
Expected value to equal:
0
Received:
1
Then(
"the observer should see the turtle at x = {int}, y = {int}, angle = {int}",
function (int, int2, int3) {
// Write code here that turns the phrase above
// into concrete actions
return "pending";
});
<polygon
points="-5,5, 0,-7, 5,5"
fill="green"
stroke-width="2"
stroke="black"
transform="rotate(90, 0, 0)" />
We can use the first points coordinate to calculate x and y, by adding 5 to the first number and subtracting 5 from the second. The angle can be calculated by taking the first parameter to rotate and subtracting 90. Create a new file named features/support/turtle.js, and then add the following two definitions:
export const calculateTurtleXYFromPoints = points => {
const firstComma = points.indexOf(",");
const secondComma = points.indexOf(
",",
firstComma + 1
);
return {
x:
parseFloat(
points.substring(0, firstComma)
) + 5,
y:
parseFloat(
points.substring(firstComma + 1, secondComma)
) - 5
};
};
export const calculateTurtleAngleFromTransform = (
transform
) => {
const firstParen = transform.indexOf("(");
const firstComma = transform.indexOf(",");
return (
parseFloat(
transform.substring(
firstParen + 1,
firstComma
)
) - 90
);
}
Then(
"the observer should see the turtle at x = {int}, y = {int}, angle = {int}",
async function (
expectedX, expectedY, expectedAngle
) {
await this.getPage(
"observer"
).waitForTimeout(4000);
const turtle = await this.getPage(
"observer"
).$eval(
"polygon",
polygon => ({
points: polygon.getAttribute("points"),
transform: polygon.getAttribute("transform")
})
);
const position = calculateTurtleXYFromPoints(
turtle.points
);
const angle = calculateTurtleAngleFromTransform(
turtle.transform
);
expect(position.x).toBeCloseTo(expectedX);
expect(position.y).toBeCloseTo(expectedY);
expect(angle).toBeCloseTo(expectedAngle);
}
);
Then(
"the presenter should see the turtle at x = {int}, y = {int}, angle = {int}",
async function (
expectedX, expectedY, expectedAngle
) {
await this.getPage(
"presenter"
).waitForTimeout(4000);
const turtle = await this.getPage(
"presenter"
).$eval(
"polygon",
polygon => ({
points: polygon.getAttribute("points"),
transform: polygon.getAttribute("transform")
})
);
const position = calculateTurtleXYFromPoints(
turtle.points
);
const angle = calculateTurtleAngleFromTransform(
turtle.transform
);
expect(position.x).toBeCloseTo(expectedX);
expect(position.y).toBeCloseTo(expectedY);
expect(angle).toBeCloseTo(expectedAngle);
}
);
That’s the first test; now, let’s move on to the second scenario:
Then these lines should have been drawn for the observer:
| x1 | y1 | x2 | y2 |
| 0 | 0 | 10 | 0 |
And these lines should have been drawn for the presenter:
| x1 | y1 | x2 | y2 |
| 0 | 0 | 10 | 0 |
We already have a step definition that is very similar to these two in features/support/drawing.steps.js. Let’s extract that logic into its own module so that we can reuse it. Create a new file named features/support/svg.js and then duplicate the following code from the drawing step definitions:
import expect from "expect";
export const checkLinesFromDataTable = page =>
return async function (dataTable) {
await this.getPage(page).waitForTimeout(2000);
const lines = await this.getPage(page).$$eval(
"line",
lines =>
lines.map(line => ({
x1: parseFloat(line.getAttribute("x1")),
y1: parseFloat(line.getAttribute("y1")),
x2: parseFloat(line.getAttribute("x2")),
y2: parseFloat(line.getAttribute("y2"))
}))
);
for (let i = 0; i < lines.length; ++i) {
expect(lines[i].x1).toBeCloseTo(
parseInt(dataTable.hashes()[i].x1)
);
expect(lines[i].y1).toBeCloseTo(
parseInt(dataTable.hashes()[i].y1)
);
expect(lines[i].x2).toBeCloseTo(
parseInt(dataTable.hashes()[i].x2)
);
expect(lines[i].y2).toBeCloseTo(
parseInt(dataTable.hashes()[i].y2)
);
}
};
import { checkLinesFromDataTable } from "./svg";
Then(
"these lines should have been drawn:",
checkLinesFromDataTable("user")
);
import { checkLinesFromDataTable } from "./svg";
Then(
"these lines should have been drawn for the presenter:",
checkLinesFromDataTable("presenter")
);
Then(
"these lines should have been drawn for the observer:",
checkLinesFromDataTable("observer")
);
You’ve now seen how to write longer step definitions and how to extract common functionality into support functions.
With the step definitions complete, it’s time to make both these scenarios pass.
In this section, we’ll start by doing a little up-front design, then we’ll write unit tests that cover the same functionality as the Cucumber tests, and then use those to build out the new implementation.
Let’s do a little up-front design:
{ type: "START_SHARING", reset: true }
{ type: "START_SHARING", reset: false }
That’s all the up-front design we need. Let’s move on to integrating the Dialog component.
Now that we know what we’re building, let’s go for it! To do so, perform these steps:
it.skip("dispatches an action of START_SHARING when start sharing is clicked", () => {
...
});
import { Dialog } from "../src/Dialog";
jest.mock("../src/Dialog", () => ({
Dialog: jest.fn(() => <div id="Dialog" />),
});
it("opens a dialog when start sharing is clicked", () => {
renderWithStore(<MenuButtons />);
click(buttonWithLabel("Start sharing"));
expect(Dialog).toBeCalled();
});
import { Dialog } from "./Dialog";
export const MenuButtons = () => {
...
return (
<>
...
<Dialog />
</>
);
};
it("prints a useful message in the sharing dialog", () => {
renderWithStore(<MenuButtons />);
click(buttonWithLabel("Start sharing"));
expect(propsOf(Dialog).message).toEqual(
"Do you want to share your previous commands, or would you like to reset to a blank script?"
);
});
<Dialog
message="Do you want to share your previous commands, or would you like to reset to a blank script?"
/>
it("does not initially show the dialog", () => {
renderWithStore(<MenuButtons />);
expect(Dialog).not.toBeCalled();
});
import React, { useState } from "react";
export const MenuButtons = () => {
const [
isSharingDialogOpen, setIsSharingDialogOpen
] = useState(false);
const openSharingDialog = () =>
setIsSharingDialogOpen(true);
...
return (
<>
...
{environment.isSharing ? (
<button
id="stopSharing"
onClick={() => dispatch(stopSharing())}
>
Stop sharing
</button>
) : (
<button
id="startSharing"
onClick={openSharingDialog}
>
Start sharing
</button>
)}
{isSharingDialogOpen ? (
<Dialog
message="..."
/>
) : null}
</>
);
};
it("passes Share and Reset buttons to the dialog", () => {
renderWithStore(<MenuButtons />);
click(buttonWithLabel("Start sharing"));
expect(propsOf(Dialog).buttons).toEqual([
{ id: "keep", text: "Share previous" },
{ id: "reset", text: "Reset" }
]);
});
{isSharingDialogOpen ? (
<Dialog
message="..."
buttons={[
{ id: "keep", text: "Share previous" },
{ id: "reset", text: "Reset" }
]}
/>
) : null}
const closeDialog = () =>
act(() => propsOf(Dialog).onClose());
it("closes the dialog when the onClose prop is called", () => {
renderWithStore(<MenuButtons />);
click(buttonWithLabel("Start sharing"));
closeDialog();
expect(element("#dialog")).toBeNull();
});
<Dialog
onClose={() => setIsSharingDialogOpen(false)}
...
/>
const makeDialogChoice = button =>
act(() => propsOf(Dialog).onChoose(button));
it("dispatches an action of START_SHARING when dialog onChoose prop is invoked with reset", () => {
renderWithStore(<MenuButtons />);
click(buttonWithLabel("Start sharing"));
makeDialogChoice("reset");
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "START_SHARING", reset: true });
});
const startSharing = () => ({
type: "START_SHARING",
reset: true,
});
Triangulation within tests
See Chapter 1, First Steps with Test-Driven Development, for a reminder on triangulation and why we do it.
return (
<>
...
{isSharingDialogOpen ? (
<Dialog
onClose={() => setIsSharingDialogOpen(false)}
onChoose={() => dispatch(startSharing())}
...
/>
) : null}
</>
);
it("dispatches an action of START_SHARING when dialog onChoose prop is invoked with share", () => {
renderWithStore(<MenuButtons />);
click(buttonWithLabel("Start sharing"));
makeDialogChoice("share");
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: "START_SHARING",
reset: false
});
});
const startSharing = (button) => ({
type: "START_SHARING",
reset: button === "reset",
});
You’ve now completed the first new piece of functionality specified in the Cucumber test. There’s a dialog box being displayed and a reset Boolean flag being sent through to the Redux store. We are inching toward a working solution.
Now, we need to update the sharing saga to handle the new reset flag:
it("puts an action of RESET if reset is true", async () => {
store.dispatch({
type: "START_SHARING",
reset: true,
});
await notifySocketOpened();
await sendSocketMessage({
type: "UNKNOWN",
id: 123,
});
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "RESET" });
});
function* startSharing(action) {
...
if (action.reset) {
yield put({ type: "RESET" });
}
}
it("shares all existing actions if reset is false", async () => {
const forward10 = {
type: "SUBMIT_EDIT_LINE",
text: "forward 10",
};
const right90 = {
type: "SUBMIT_EDIT_LINE",
text: "right 90"
};
store.dispatch(forward10);
store.dispatch(right90);
store.dispatch({
type: "START_SHARING",
reset: false,
});
await notifySocketOpened();
await sendSocketMessage({
type: "UNKNOWN",
id: 123,
});
expect(sendSpy).toBeCalledWith(
JSON.stringify({
type: "NEW_ACTION",
innerAction: forward10,
})
);
expect(sendSpy).toBeCalledWith(
JSON.stringify({
type: "NEW_ACTION",
innerAction: right90
})
);
});
import {
call,
put,
takeLatest,
take,
all,
select
} from "redux-saga/effects";
import { eventChannel, END } from "redux-saga";
import { toInstructions } from "../language/export";
if (action.reset) {
yield put({ type: "RESET" });
} else {
const state = yield select(state => state.script);
const instructions = toInstructions(state);
yield all(
instructions.map(instruction =>
call(shareNewAction, {
innerAction: {
type: "SUBMIT_EDIT_LINE",
text: instruction
}
})
)
);
}
const startSharing = async () => {
store.dispatch({
type: "START_SHARING",
reset: true
});
...
};
That completes the feature; both the unit tests and the Cucumber tests should be passing. Now would be a great time to try things out manually, too.
In the next section, we’ll focus on reworking our Cucumber tests to make them run much faster.
In this section, we’ll improve the speed at which our Cucumber tests run by replacing waitForTimeout calls with waitForSelector calls.
Many of our step definitions contain waits that pause our test script interaction with the browser while we wait for the animations to finish. Here’s an example from our tests, which waits for a period of 3 seconds:
await this.getPage("user").waitForTimeout(3000);
Not only will this timeout slow down the test suite, but this kind of wait is also brittle as there are likely to be occasions when the timeout is slightly too short and the animation hasn’t finished. In this case, the test will intermittently fail. Conversely, the wait period is actually quite long. As more tests are added, the timeouts add up and the test runs suddenly take forever to run.
Avoiding timeouts
Regardless of the type of automated test, it is a good idea to avoid timeouts in your test code. Timeouts will substantially increase the time it takes to run your test suite. There are almost always methods you can use to avoid using them, such as the one highlighted in this section.
What we can do instead is modify our production code to notify us when it is animating, by setting an isAnimating class when the element is animating. We then use the Puppeteer waitForSelector function to check for a change in the value of this class, replacing waitForTimeout entirely.
We do this by adding an isAnimating class to the viewport div element when an animation is running.
Let’s start by adding the isAnimating class when the Drawing element is ready to animate a new Logo command:
describe("isAnimating", () => {
it("adds isAnimating class to viewport when animation begins", () => {
renderWithStore(<Drawing />, {
script: { drawCommands: [horizontalLine] }
});
triggerRequestAnimationFrame(0);
expect(
element("#viewport")
).toHaveClass("isAnimating");
});
});
return (
<div
id="viewport"
className="isAnimating"
>
...
</div>
);
it("initially does not have the isAnimating class set", () => {
renderWithStore(<Drawing />, {
script: { drawCommands: [] }
});
expect(
element("#viewport")
).not.toHaveClass("isAnimating");
});
className={commandToAnimate ? "isAnimating" : ""}>
it("removes isAnimating class when animation is finished", () => {
renderWithStore(<Drawing />, {
script: { drawCommands: [horizontalLine] },
});
triggerAnimationSequence([0, 500]);
expect(element("#viewport")).not.toHaveClass(
"isAnimating"
);
});
That completes adding the isAnimating class functionality. Now we can use this class as a means of replacing the waitForTimeout calls.
We’re ready to use this new behavior in our step definitions, bringing in a new call to waitForSelector that waits until the isAnimating class appears (or disappears) on an element:
waitForAnimationToBegin(page) {
return this.getPage(page).waitForSelector(
".isAnimating"
);
}
waitForAnimationToEnd(page) {
return this.getPage(page).waitForSelector(
".isAnimating",
{ hidden: true }
);
}
When(
"the user enters the following instructions at the prompt:",
async function (dataTable) {
for (let instruction of dataTable.raw()) {
await this.getPage("user").type(
"textarea",
`${instruction}\n`
);
await this.waitForAnimationToEnd("user");
}
}
);
Being careful about class transitions
We’re waiting for animation after each instruction is entered. This is important as it mirrors how the isAnimating class will be added and removed from the application. If we only had one waitForAnimationToEnd function as the last instruction on the page, we may end up exiting the step definition early if the wait catches the removal of an isAnimating class in the middle of a sequence of instructions, rather than catching the last one.
When(
"the presenter entered the following instructions at the prompt:",
async function(dataTable) {
for (let instruction of dataTable.raw()) {
await this.getPage("presenter").type(
"textarea",
`${instruction}\n`
);
await this.waitForAnimationToEnd("presenter");
}
}
);
Then(
"the observer should see the turtle at x = {int}, y = {int}, angle = {int}",
async function (
expectedX, expectedY, expectedAngle
) {
await this.waitForAnimationToEnd("observer");
...
}
);
Then(
"the presenter should see the turtle at x = {int}, y = {int}, angle = {int}",
async function (
expectedX, expectedY, expectedAngle
) {
await this.waitForAnimationToEnd("presenter");
...
}
);
export const checkLinesFromDataTable = page => {
return async function (dataTable) {
await this.waitForAnimationToEnd(page);
...
}
};
When the presenter clicks the button 'keep'
And the observer navigates to the presenter's sharing link
And the observer waits for animations to finish
Encapsulating multiple When clauses
If you aren’t happy with having three When clauses, then you can always combine them into a single step.
When(
"the observer waits for animations to finish",
async function () {
await this.waitForAnimationToBegin("observer");
await this.waitForAnimationToEnd("observer");
}
);
Your tests should now be passing, and they should be much faster. On my machine, they now only take a quarter of the time than they did before.
In this chapter, we looked at how you can integrate Cucumber into your team’s workflow.
You saw some more ways that Cucumber tests differ from unit tests. You also learned how to avoid using timeouts to keep your test suites speedy.
We’re now finished with our exploration of the Spec Logo world.
In the final chapter of the book, we’ll look at how TDD compares to other developer processes.
Remove as much duplication as possible from your step definitions.
Besides the mechanics of test-driven development, this book has touched on a few ideas about the mindset of the TDD practitioner: how and when to “cheat,” systematic refactoring, strict TDD, and so on.
Some dev teams like to adopt the mantra of move fast and break things. TDD is the opposite: go slow and think about things. To understand what this means in practice, we can compare TDD with various other popular testing techniques.
The following topics will be covered in this chapter:
By the end of this chapter, you should have a good idea of why and how we practice TDD compared to other programming practices.
TDD practitioners sometimes like to say that TDD is not about testing; rather, it’s about design, behavior, or specification, and the automated tests we have at the end are simply a bonus.
Yes, TDD is about design, but TDD is certainly about testing, too. TDD practitioners care that their software has a high level of quality, and this is the same thing that testers care about.
Sometimes, people question the naming of TDD because they feel that the notion of a “test” confuses the actual process. The reason for this is that developers misunderstand what it means to build a “test.” A typical unit testing tool offers you practically no guidance on how to write good tests. And it turns out that reframing tests as specifications and examples is a good way to introduce testing to developers.
All automated tests are hard to write. Sometimes, we forget to write important tests, or we build brittle tests, write loose expectations, over-complicate solutions, forget to refactor, and so on.
It’s not just novices who struggle with this – everyone does it, experts included. People make a mess all the time. That’s also part of the fun. Discovering the joy of TDD requires a certain degree of humility and accepting that you aren’t going to be writing pristine test suites most of the time. Pristine test suites are very rare indeed.
If you are lucky enough to have a tester on your team, you may think that TDD encroaches on their work, or may even put them out of a job. However, if you ask them their opinion, you’ll undoubtedly find they are only too keen for developers to take an interest in the quality of their work. With TDD, you can catch all those trivial logic errors yourself without needing to rely on someone else’s manual testing. The testers can then better use their time by focusing on testing complex use cases and hunting down missed requirements.
The following are some great unit tests:
Classicist versus mockist TDD
You may have heard of the great TDD debate of classicist versus mockist TDD. The idea is that the classicist will not use mocks and stubs, while the mockist will mock all collaborators. In reality, both techniques are important. You have seen both in use in this book. I encourage you to not limit yourself to a single approach, but instead experiment and learn to be comfortable with both.
TDD is not a replacement for great design. To be a great TDD practitioner, you should also learn about and practice software design. There are many books about software design. Do not limit yourself to books about JavaScript or TypeScript; good design transcends language.
The following are some general tips for improving:
That covers the general advice on TDD. Next, let’s look at manual testing techniques.
Manual testing, as you may have guessed, means starting your application and actually using it.
Since your software is your creative work, naturally, you are interested to find out how it performs. You should certainly take the time to do this but think of it as downtime and a chance to relax, rather than a formal part of your development process.
The downside to using your software as opposed to developing your software is that using it takes up a lot of time. It sounds silly but pointing, clicking, and typing all take up valuable time. Plus, it takes time to get test environments set up and primed with the relevant test data.
For this reason, it’s important to avoid manual testing where possible. There are, however, times when it’s necessary, as we’ll discover in this section.
There is always a temptation to manually test the software after each feature is complete, just to verify that it works. If you find yourself doing this a lot, consider how much confidence you have in your unit tests.
If you can claim, “I have 100% confidence in my unit tests,” why would you ever need to use your software to prove it?
Let’s look at some specific types of manual testing, starting with demonstrating software.
There are at least two important occasions where you should always manually test: when you are demonstrating your software to your customers and users, and when you are preparing to demonstrate your software.
Preparing means writing down a demo script that lists every action you want to perform. Rehearse your script at least a couple of times before you perform it live. Very often, rehearsals will bring about changes to the script, which is why rehearsals are so important. Always make sure you’ve done at least one full run-through that didn’t require changes before you perform a live demo.
Frontend development includes a lot of moving parts, including the following:
Manually testing is necessary because of the interaction of all these moving parts. We need to check that everything sits together nicely.
Alternatively, you can use end-to-end tests for the same coverage; however, these are costly to develop and maintain.
Exploratory testing is what you want your QA team to do. If you don’t work with a QA team, you should allocate time to do this yourself. Exploratory testing involves exploring software and hunting for missing requirements or complex use cases that your team has not thought about yet.
Because TDD works at a very low level, it can be easy to miss or even misunderstand requirements. Your unit tests may cover 95% of cases, but you can accidentally forget about the remaining 5%. This happens a lot when a team is new to TDD, or is made up of novice programmers. It happens all the time with experienced TDDers, too – even those of us who write books on TDD! We all make errors from time to time.
A very common error scenario involves mocks. When a class or function signature is changed, any mocks of that class or function must also be updated. This step is often forgotten; the unit tests still pass, and the error is only discovered when you run the application for real.
Bug-free software
TDD can give you more confidence, but there is absolutely no way that TDD guarantees bug-free software.
With time and experience, you’ll get better at spotting all those pesky edge cases before they make it to the QA team.
An alternative to exploratory testing is automated acceptance tests, but as with end-to-end tests, these are costly to develop and maintain, and they also require a high level of expertise and team discipline.
Debugging is always an epic time sink. It can be an incredibly frustrating experience, with a lot of hair-pulling. That’s a big reason we test-drive: so that we never have to debug. Our tests do the debugging for us.
Conversely, a downside of TDD is that your debugging skills will languish.
For the TDD practitioner, debugging should, in theory, be a very rare experience, or at least something that is actively avoided. But there are always occasions when debugging is necessary.
Print-line debugging is the name given to the debugging technique where a code base is littered with console.log statements in the hope that they can provide runtime clues about what’s going wrong. I’ve worked with many programmers who began their careers with TDD; for many of them, print-line debugging is the only form of debugging they know. Although it’s a simple technique, it’s also time-consuming, involves a lot of trial and error, and you have to remember to clean up after yourself when you’re done. There’s a risk of accidentally forgetting about a stray console.log and it then going live in production.
Modern browsers have very sophisticated debugging tools that, until just recently, would have been imaginable only in a “full-fat” IDE such as Visual Studio or IntelliJ. You should make time to learn about all of the standard debugging techniques, including setting breakpoints (including conditional breakpoints), stepping in, out, and over, watching variables, and so on.
A common anti-pattern is to use debugging techniques to track down a bug, and once it’s discovered, fix it and move on to the next task. What you should be doing instead is writing a failing test to prove the existence of a bug. As if by magic, the test has done the debugging for you. Then, you can fix the bug, and immediately, the test will tell you whether the issue has been fixed, without you needing to manually re-test. Think of all the time you’ll save!
Check out the Further reading section for resources on the Chrome debugger.
That covers the main types of manual testing that you’ll perform. Next, let’s take a look at automated testing techniques.
TDD is a form of automated testing. This section lists some other popular types of automated testing and how they compare to TDD.
These tests check how two or more independent processes interact. Those processes could either be on the same machine or distributed across a network. However, your system should exercise the same communication mechanisms as it would in production, so if it makes HTTP calls out to a web service, then it should do so in your integration tests, regardless of where the web service is running.
Integration tests should be written in the same unit test framework that you use for unit tests, and all of the same rules about writing good unit tests apply to integration tests.
The trickiest part of integration testing is the orchestration code, which involves starting and stopping processes, and waiting for processes to complete their work. Doing that reliably can be difficult.
If you’re choosing to mock objects in your unit tests, you will need at least some coverage of those interactions when they aren’t mocked, and integration tests are one way to do that. Another way is system testing, as discussed below.
These are automated tests that exercise the entire system, usually (but not necessarily) by driving a UI.
They are useful when manual exploratory testing starts taking an inordinate amount of time. This happens with codebases as they grow in size and age.
End-to-end tests are costly to build and maintain. Fortunately, they can be introduced gradually, so you can start small and prove their value before increasing their scope.
Acceptance tests are written by the customer, or a proxy to the customer such as a product owner, where acceptance refers to a quality gate that must be passed for the released software to be accepted as complete. They may or may not be automated, and they specify behavior at a system level.
How should the customer write these tests? For automated tests, you can often use system testing tools such as Cucumber and Cypress. The Gherkin syntax that we saw in Chapter 17, Writing Your First Cucumber Test, and Chapter 18, Adding Features Guided by Cucumber Tests, is one way to do it.
Acceptance tests can be used to build trust between developers and product stakeholders. If the customer is endlessly testing your software looking for bugs, that points to a low level of trust between the development team and the outside world. Acceptance tests could help improve that trust if they start catching bugs that would otherwise be found by your customer. At the same time, however, you should be asking yourself why TDD isn’t catching all those bugs in the first place and consider how you can improve your overall testing process.
In traditional TDD, we find a small set of specifications or examples to test our functions against. Property-based testing is different: it generates a large set of tests based on a definition of what the inputs to those functions should be. The test framework is responsible for generating the input data and the tests.
For example, if I had a function that converted Fahrenheit to Celsius, I could use a generative test framework to generate tests for a large, random sample of integer-valued Fahrenheit measurements and ensure that each of them converts into the correct Celsius value.
Property-based testing is just as hard as TDD. It is no magic bullet. Finding the right properties to assert is challenging, particularly if you aim to build them up in a test-driven style.
This kind of testing does not replace TDD, but it is another tool in any TDD practitioner’s toolbox.
This is a popular testing technique for React applications. React component trees are serialized to disk as a JSON string and then compared between test runs.
React component trees are useful in a couple of important scenarios, including the following:
QA teams are sometimes interested in how software changes visually between releases, but they will probably not want to write tests in your unit test suites; they’ll have their own specialized tool for that.
Snapshot testing is certainly a useful tool to know about, but be aware of the following issues:
When writing good tests (of any kind), you want the following to be true of any test failure that occurs:
TDD is an established technique that the community has learned enough about to know how to write good tests. We aren’t quite there with snapshot testing. If you absolutely must employ snapshot testing in your code base, make sure that you measure how much value it is providing you and your team.
Canary testing is when you release your software to a small proportion of your users and see what happens. It can be useful for web applications with a large user base. One version of canary testing involves sending each request to two systems: the live system and the system under test. Users only sense the live system but the test system results are recorded and analyzed by you. Differences in functionality and performance can then be observed, while your users are never subjected to test software.
Canary testing is attractive because, on the surface, it seems very cost-effective, and also requires next to no thinking from the programmer.
Unlike TDD, canary testing cannot help you with the design of your software, and it may take a while for you to get any feedback.
That completes our look at the automated testing landscape. We started this chapter by looking at manual testing techniques. Now, let’s round this chapter off with a final technique: not testing at all!
There is a belief that TDD doesn’t apply to some scenarios in which it does – for example, if your code is throwaway or if it’s presumed to never need modification once it’s deployed. Believing this is almost ensuring the opposite is true. Code, particularly code without tests, has a habit of living on beyond its intended lifespan.
Fear of deleting code
In addition to reducing the fear of changing code, tests also reduce the fear of removing code. Without tests, you’ll read some code and think “maybe something uses this code for some purpose I don’t quite remember.” With tests in place, this won’t be a concern. You’ll read the test, see that the test no longer applies due to a changed requirement, and then delete the test and its corresponding production code.
However, there are several scenarios in which not writing tests is acceptable. The two most important ones are as follows.
Unfortunately, in many environments, quality doesn’t matter. Many of us can relate to this. We’ve worked for employers who actively disregard quality. These people make enough profit that they don’t need or want to care. Caring about quality is, unfortunately, a personal choice. If you are in a team that does not value quality, it will be hard to convince them that TDD is worthwhile.
If you’re in this situation and you have a burning desire to use TDD, then you have a few options. First, you can spend time convincing your colleagues that it is a good idea. This is never an easy task. You could also play the TDD-by-stealth game, in which you don’t ask permission before you start. Failing these options, some programmers will be fortunate enough that they can take the risk of finding an alternative employer that does value quality.
Spiking means coding without tests. We spike when we’re in uncharted territory. We need to find a workable approach to a problem we’ve never solved before, and there is likely to be a great deal of trial and error, along with a lot of backtracking. There is a high chance of finding unworkable approaches before a workable one. Writing tests doesn’t make much sense in this situation because many of the tests written along the way will ultimately end up being scrapped.
Let’s say, for example, that I’m building a web socket server and client, but it’s the first time I’ve used WebSockets. This would be a good candidate for spiking – I can safely explore the WebSocket API until I’m comfortable baking it into my application.
It’s important to stop spiking when you feel that you’ve hit on a workable approach. You don’t need a complete solution, just one that teaches you enough to set you off on the right path.
In the purist vision of TDD, spiking must be followed by deleting. If you’re going to spike, you must be comfortable with deleting your work. Unfortunately, that’s easier said than done; it’s hard to scrub out creative output. You must shake off the belief that your code is sacred. Be happy to chuck it away.
In the pragmatic vision of TDD, spiking can often be followed by writing tests around the spiked code. I use this technique all the time. If you’re new to TDD, it may be wise to avoid this particular cheat until you’re confident that you can think out a test sequence of required tests that will cover all the required functionality within spike code.
A purist may say that your spike code can include redundant code, and it may not be the simplest solution because tests will not have driven the implementation. There is some merit to this argument.
Spiking and test-last development
Spiking is related to the practice of test last, but there’s a subtle difference. Writing code around a spike is a TDD cheat in that you want your finished tests to look as if you used TDD in the first place. Anyone else coming along after you should never know that you cheated.
Test last, however, is a more loosely defined way of testing where you write all the production code and then write some unit tests that prove some of the more important use cases. Writing tests like this gives you some level of regression coverage but none of the other benefits of TDD.
Becoming a great practitioner of TDD takes great effort. It requires practice, experience, determination, and discipline.
Many people have tried TDD and failed. Some of them will conclude that TDD is broken. But I don’t believe it’s broken. It just takes effort and patience to get right.
But what is getting it right, anyway?
All software development techniques are subjective. Everything in this book is subjective; it is not the right way. It is a collection of techniques that I like to use, and that I have found success with. Other people have found success with other techniques.
The exciting part of TDD is not the black-and-white, strict form of the process; it is the grays in which we can define (and refine) a development process that works for us and our colleagues. The TDD cycle gives us just enough structure that we can find joy in fleshing it out with our rules and our own dogma.
I hope you have found this book valuable and enjoyable. There are many, many ways to test-drive React applications and I hope that this is the launchpad for you to evolve your testing practice.
To learn more about the topics that were covered in this chapter, take a look at the following resources:
https://github.com/sandromancuso/Bank-kata
http://www.natpryce.com/articles/000807.html
As this ebook edition doesn't have fixed pagination, the page numbers below are hyperlinked for reference only, based on the printed edition of this book.
A
acceptance tests 519
accessibility
element description 249
Accessible Rich Internet Applications (ARIA)
act function
reference link 19
addAppointment 212
after parameter 268
alert role
using, on multiple elements 249
AnimatedLine component
animation
canceling, with cancelAnimationFrame 429-431
turtle position, using for 422
animation status
anonymous functions 472
App component
about 209
state, using to control active view 211-219
appointments
Arrange, Act, and Assert (AAA) pattern
Array.from function 270
array patterns
asserting on 114
ASCII escape codes
testing 66
Assert phase 150
async act 445
asynchronous requests
automated testing
about 518
acceptance tests 519
canary testing 521
end-to-end tests 519
generative testing 519
integration tests 518
property-based testing 520
snapshot testing 520
system tests 519
B
Babel
about 7
.babelrc configuration 7
configuring 7
beforeEach function 30
Behavior Driven Development (BDD) 467
black box 188
BrowserRouter component 294
builders
fetchResponseError 173, 177, 183
C
calendar view
constructing 111
callback props, test-driving 219-224
callback values
canary testing 521
child components
initial props, testing 188-191
mocking 186
Chromium 468
classicist TDD
versus mockist TDD 514
client-side validation
generalizing, for multiple fields 237-246
non-React functionality, extracting into module 249-252
required field, validating 233-237
collaborating objects 148
combineReducers function 323
commit early and often 6
components
placing 35
component state
switching, for Redux state 334
constants
defining 126
const keyword 26
createRoot function 14
createStore function 323
CSS selectors
Cucumber tests
Chromium 468
code, timeouts avoiding 505, 506
data tables 478
data tables, using to perform setup 478-483
debugging output 478
double When phrase 488
encapsulating When phrases 511
fixing, by test-driving production code 496
Gherkin syntaxCucumber 468-471
integrating, into code base 469
sagas, updating to reset or replay state 502-505
World object 472
Cypress 468
D
data
refactoring, to simplify component design 283, 284
selecting 43
data-testid 190
date handling
setHours 118, 121, 123, 125, 137, 138, 139
toShortDate 119
default exports 16
deleting code 521
dialog box
Cucumber tests, adding to 487-495
Document Object Model (DOM)
about 12
className 72
defaultPrevented 87
dispatchEvent 87
event
click 46
events
Form API 79
querySelectorAll 38, 41, 46, 47
Don’t Repeat Yourself (DRY) 25
E
ECMAScript 206
element positioning
testing 46
element testing
form 79
input 79
end-to-end tests 519
Environment object construction
errorFor 240
errors
server 422 error 253
errorSend fake 367
eventChannel function 459
expect function
expect.anything 156, 161, 166, 179
expect.arrayContaining 156, 161
expect.objectContaining 156, 161, 166
expected value 11
expect-redux
using, to write expectations 325-328
expect-redux package 324
expect-utils package 203
exploratory testing 517
F
failing test
fake object 149
fakes
avoiding 149
false positive 21
fetch
test-driving arguments 156
test-driving return value 159, 169
fetch API
global functions, replacing with spies 158, 159
side-by-side implementation, used for reworking on existing tests 162-166
spy expectations, improving with helper functions 166, 167
test-driving fetch argument values 159-162
fetch responses
acting on 168
asynchronous form, of act 169
async tasks, adding to existing components 169- 174
errors, displaying to user 174-177
spies, upgrading to stubs 168
stub scenarios, grouping in nested describe contexts 177, 178
focus 407
form
changed values, submitting 90-94
default submit action, preventing 87-90
submitting 84
submitting, without any changes 84-87
form element
forms
about 258
submitting, with spies 149, 150
submitting indicator 254
testing, before promise completion 255-258
validation 231
functional component
functional update variant
using 422
G
generative testing 520
generator functions
pulling out, for reducer actions 322, 323
getOwnPropertyDescriptor 90, 92
gitignore file 53
Given clause 471
Given phrase 487
Given, When, Then 472
global.fetch (see fetch)
Jest configuration 179
global functions
replacing, with spies 158, 159
GraphQL
about 345
data, fetching from within component 356-369
H
hardcoded string value
helper functions
spy expectations, improving with 166, 167
history package 341
HistoryRouter component 294
HTML classes
adding, to mark animation status 506-508
hyperlinks
HyperText Transfer Protocol (HTTP) 148
I
ID attribute
use 190
initializeReactContainer 58
input controls
integration tests 518
intermediate components
using, to translate URL state 300-303
it function 10
it.only function 151, 152, 166
J
JavaScript Object Notation (JSON) 157
JavaScript Syntax Extension (JSX) 7
Jest
alternatives 6
beforeEach function 30
commit early and often 6
configuration
setupFilesAfterEnv 182
creating 5
describe function 9
expect function 12-18, 21-24, 27
expect.hasAssertions 85
global functions 9
installing 6
it function 10
it.only function 151, 152, 166
jest.mock function 187
matcherHint 66
mockResolvedValue 178, 180, 183
running tests 9
test function 10
this.equals 156
watchAll flag 30
Jest matcher
jest.mock call
mocked components children, rendering 205
module mocks, alternatives 206
multiple instances of rendered component, checking 205
spy function, removing 205
Jest test-double support
fetch test functionality, extracting 182, 183
migrating to 178
jsdom 12
K
kata 515
keyboard focus
modifying 402
requesting, in components 408, 409
L
let keyword 26
Link component
about 294
list item content
list validator function 245
M
manual testing
debugging, in browser 518
exploratory testing 517
software, demonstrating 516
whole product, testing 517
manual testing, of changes
about 49
matchers
building, for component mocks 200-204
toBeCalledWith 154, 156, 166, 173, 178, 179, 181
toBeCloseTo 483
toBeFirstRenderedWithProps 200, 204
toContain function 11
toHaveBeenLastCalledWith 196-200
toMatchObject 317-321, 378, 381
using, to simplify spy expectations 154-157
match validator function 244
memoized callback 129
MemoryRoute
versus HistoryRouter 297
message function 65
middleware, test-driving 328
Mocha 6
mocked components
alternatives 206
children, rendering 205
mockImplementation 443
mocking constructors 354
mockist TDD
versus classicist TDD 514
mock-up
sketching 34
module factory parameter 187
multiple form fields
batch of tests, solving 98, 99
describe blocks, nesting 94, 95
handleChange, modifying so that it works with 100
parameterized tests, generating 95-98
testing 100
tests, duplicating 94
multiple pages dataset
design changes, forcing with tests 277, 278
moving, between 268
Next button, adding to move to next page 268-272
Previous button, adding to move to previous page 274-277
N
name property 126
nested describe block 101
nested describe contexts
stub, scenarios grouping in 177, 178
Node.js
URL 5
Node Package Manager (npm)
installation 5
package.json configuration 5, 10, 12, 19, 30
version 5
non-React functionality
extracting, into module 249-252
noSend fake 366
not.toBeCalled 452
P
package installs
Babel 7
Jest 6
JSDOM 12
React 7
react-router-dom 296, 300, 303, 306, 307, 310
package.json scripts
using, advantages 470
pair programming 20
parameterized tests. See test generator functions
about 75
performFetch function
ping pong programming 20
predictable state container 313
programmatic navigation
property-based testing 520
Provider component 324
Puppeteer
console logging 478
headless mode, turning off 468, 478
integrating, into code base 469
Page.click 473
Page.waitForSelector 489, 505, 508
avoiding 505
Q
queryByTestId functionrenderAndWait 190
query strings 276-283, 295, 301, 303
R
radio button
radio button groups
field changes, handling 129-136
input controls, hiding 122-127
React
about 80
act function 19
container 14
createRoot function 14
defaultProps 106-110, 117, 120
hooks 45
htmlFor attribute 83
key values, testing 40
render function 18
Simulate 47
React component trees
uses 520
React form
submitting, by dispatching Redux action 334-336
React project
creating 4
data, displaying with first test 8
React Router applications
designing, from test-first perspective 294
pieces, using 294
tests, splitting 294
up-front design, for new routes 295
React Testing Library 190
red, green, refactor cycle 28, 29
REDO action
reducer
design 314
reducer, test-driving
entry point, setting up 323, 324
generator functions, pulling out for 322, 323
Redux
about 313
need for 314
store actions, designing 315, 316
store state, designing 315, 316
user actions, undoing and redoing 376
withUndoRedo reducer 376, 383, 389
Redux action
React form, submitting by dispatching 334-336
Redux middleware
local storage, saving via 393, 394
Redux saga
events, streaming with 454-460
router history, navigating in 341-343
Redux state
component state, switching for 334
refactoring process
about 25
Relay environment, testing
about 346
Environment object construction, test-driving 351-355
performFetch function, building 347-351
singleton instance of Environment, test-driving 355, 356
Relay library 345
render
render function 18
renderAndWait helper
adding 193
render props
about 285
actions, performing with 284-287
testing, in additional render contexts 287-290
renderWithStore test extension
requestAnimationFrame
required validator function 240
reusable rendering logic
reusable spy function
rich internet applications (ARIA) labels 109
root saga 327
Route component 294
router
components, testing 296
Router component
about 294
router history
navigating, in Redux saga 341-343
router links
Link component, mocking 306-309
page, checking for hyperlinks 305, 306
testing 304
Routes component
about 294
using, to replace switch statement 297-299
S
saga
design 314
updating, to reset or replay state 502-505
saga, test-driving
about 324
asynchronous requests, creating 328-334
expect-redux, using to write expectations 325-328
renderWithStore test extension, adding 324, 325
sample data
Scalable Vector Graphics (SVG)
about 412
polygon element 491
scenarios, for not writing code
code, deleting 522
code, spiking 522
schema
compiling 346
select box
real values, verifying 106
value, selecting 104
sendCustomer fake 358
server errors
shared state 22
side-by-side implementation
used, for reworking on existing tests 162-166
singleton instance, Environment
Spec Logo environment 393
Spec Logo user interface 374, 375
spies
global functions, replacing with 158, 159
upgrading, to stubs 168
used, for submitting forms 149, 150
spike 154
spiking 522
spy
about 150
removing 205
spy expectations
improving, with helper functions 166, 167
simplifying, with matcher 154-157
state
using, to control active view 211-219
store state
using, within component 337-341
Structured Query Language (SQL) 149
stub
about 167
scenarios, grouping in nested describe contexts 177, 178
spies, upgrading to 168
stubbing
window.location 444
switch statement
replacing, with Routes component 297-299
synthetic event 47
system tests 519
T
tabular data fetched
displaying, from endpoint 263-268
technical debt 5
temporal coupling 258
test data
simplifying 14
test data builders
extracting, for time and date functions 137-139
test descriptions 10
test double
fakes, avoiding 149
test-driven development
using, as testing technique 513, 514
Test-Driven Development (TDD)
about 3
code, refactoring 29
failing test, writing 28
test, making to pass 28
used, for creating Jest matcher 61-70
test-driving fetch argument values 159-162
test environment 12
test extension
about 60
dispatchToStore 335
initializeReactContainer 60
submitAndWait 172
submitButton 91
test generator functions 95
testing process
testing technique
test-last development 522
testProps objects 125, 140, 141, 144
testProps object
tests
Arrange, Act, Assert (AAA) pattern 28
locations, for storage 9
red, green, refactor cycle 28
test suite
migrating, to use Jest test-double support 179-182
text extensions
buttonWithLabel 268
text input
Then clause 471
time origin 425
timeouts
avoiding 506
avoiding, in test code 505, 506
toEqual matcher 41
turtle position
using, for animation 422
TypeScript 238
U
UI elements 442
UNDO action
Uniform Resource Locator (URL) 273
unit tests
AAA pattern 515
design tool 515
Don’t Repeat Yourself (DRY) 515
focused on observable behavior 515
independent 514
quick to run 514
short, with high level of abstraction 514
unmounting component 431
unused function arguments
ignoring 39
up-front design 34
URL state
translating, with intermediate components 300-303
useCallback hook 129, 133, 134
useDispatch hook 336
useEffect hook
dependency list, testing 198, 199
setters, using 192
used, for fetching data on mount 191
useLocation hook 294
useNavigate hook 294
user actions, in Redux
reducer, building 376
Undo and Redo buttons, building 390-393
user actions in Redux, reducer
initial state, setting 377-381
useRef 405
useSearchParams hook 294
useSelector hook 337
useState hook
functional update variant 422
W
waitForSelector
step definitions, updating to use 508-511
WebSocket
interaction, designing 440
WebSocket connection
WebSocket, interaction
saga, splitting apart 442, 443
UI elements 442
When clause 471
Y
You Ain’t Gonna Need It (YAGNI) 4

Subscribe to our online digital library for full access to over 7,000 books and videos, as well as industry leading tools to help you plan your personal development and advance your career. For more information, please visit our website.
Did you know that Packt offers eBook versions of every book published, with PDF and ePub files available? You can upgrade to the eBook version at packt.com and as a print book customer, you are entitled to a discount on the eBook copy. Get in touch with us at customercare@packtpub.com for more details.
At www.packt.com, you can also read a collection of free technical articles, sign up for a range of free newsletters, and receive exclusive discounts and offers on Packt books and eBooks.
If you enjoyed this book, you may be interested in these other books by Packt:
A Frontend Web Developer’s Guide to Testing
Eran Kinsbruner
ISBN: 978-1-80323-831-9
React 17 Design Patterns and Best Practices – Third Edition
Carlos Santana Roldán
ISBN: 978-1-80056-044-4
If you’re interested in becoming an author for Packt, please visit authors.packtpub.com and apply today. We have worked with thousands of developers and tech professionals, just like you, to help them share their insight with the global tech community. You can make a general application, apply for a specific hot topic that we are recruiting an author for, or submit your own idea.
Hi!
This is Daniel Irvine, author of Mastering React Test-Driven Development. I hope you enjoyed reading this book. It would really help me (and other potential readers) if you could leave a review on Amazon sharing your thoughts.
Go to the link below to leave your review:
https://packt.link/r/1803247126
Your review will help us to understand what’s worked well in this book, and what could be improved upon for future editions, so it really is appreciated.
Best wishes,
Other Books You May Enjoy