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