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.