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(" ");
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: