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: