This project demonstrates a comprehensive testing strategy for React components that use Apollo Client for GraphQL operations. The approach focuses on testing components in isolation while ensuring full coverage of network interactions.
The reader should already be familiar with React Testing Library and Apollo idioms. For simplicity, I've ignored Apollo type generation (I assume you have that sorted) and define my types by hand.
All code in this readme is in the project and runnable. I recommend exercising it and seeing how different modifications to the components will make the tests red.
- Components Overview
- Drawing Seams with Mocking
- Testing Query Execution
- Testing Mutation Execution
- Testing Data Refetching
- Key Testing Principles
Both of the the components here do a decent amount of things. In real-world scenarios, doing network-access and DOM rendering might be a bit too much if we're playing by SOLID-ish principles and trying to write maintainable tests.
Those scenarios could split each of these components into smart/dumb components, or use more presentation components, etc. The Dear Reader is more than capable of imagining what that would look like.
The Users component is a component that:
- Fetches and displays a list of users
- Provides a button to open a user creation dialog
- Handles loading and error states
- Refetches data after successful user creation
The CreateUserDialog component is a component that:
- Manages its own form state
- Handles the
CREATE_USERmutation - Provides success/error callbacks to parent components
DO NOT mock every child component. This goes against the Testing Library principle of "the more your tests resemble the way your software is used, the more confidence they can give you." However, when a child component becomes complex enough to have its own substantial responsibilities (network calls, complex state management, etc.), it becomes a good candidate for mocking because:
- It creates a natural seam between different areas of responsibility
- It allows you to focus each test on a specific concern
- It keeps tests fast and maintainable
The CreateUserDialog component (especially a realistic implementation) is complex and has its own network responsibilities. Rather than testing the entire integration, we draw a seam between the components by mocking the dialog.
In Users.test.tsx, we mock the CreateUserDialog component:
vitest.mock("./CreateUserDialog", () => ({
default: (props: ComponentProps<typeof CreateUserDialog>) => {
if (!props.isOpen) return null;
return (
<dialog open={props.isOpen} onClose={props.onClose}>
<button
data-testid="mock-create-user-button"
onClick={() => {
props.onSuccess({
id: "1",
name: "John Doe",
email: "[email protected]",
role: "admin",
});
props.onClose();
}}
>
Create
</button>
</dialog>
);
},
}));We delegate all implementation details of the dialog (eg, does onSuccess and onClose get called at the right time?) to its own tests.
This simple mock:
- Simulates the dialog's open/close behavior
- Provides a simple button that triggers the success callback
- Uses test IDs to clearly indicate it's a mock component
- Allows us to test the parent component's integration logic without the complexity of the real dialog.
We use Apollo Client's MockedProvider to verify that the correct queries are executed. For convenience, we're importing the query from the component under test, but a hardliner could write it out explicitly.
test("renders users if there are users", async () => {
render(
<MockedProvider
mocks={[
{
request: { query: USERS_QUERY },
result: { data: { users: [{ id: "1", name: "John Doe" }] } },
},
]}
>
<Users />
</MockedProvider>
);
expect(await screen.findByText("John Doe")).toBeVisible();
});For the CreateUserDialog component, we test that mutations are called with the correct variables. A key technique here is using the function form of MockedResponse['result']:
test("calls the createUser mutation when the form is submitted", async () => {
// Set up a mock mutation result. This uses the function form of MockedResponse['result']
// that returns a mock result. This function is called by Apollo on-demand only when
// the response is needed. This allows us to assert that a specific query/mutation
// was actually called and the response was requested.
const onMutationResult = vitest.fn(() => ({
data: {
createUser: {
id: "1",
name: "John Doe",
email: "[email protected]",
role: "admin",
},
},
}));
render(
<MockedProvider
mocks={[
{
request: {
query: CREATE_USER_MUTATION,
variables: {
name: "John Doe",
email: "[email protected]",
role: "admin",
},
},
result: onMutationResult, // Function form, not a plain object
},
]}
>
<CreateUserDialog />
</MockedProvider>
);
// ...
await waitFor(() => expect(onMutationResult).toHaveBeenCalled());
});The function form of MockedResponse['result'] is crucial because the function is only called when Apollo actually needs the response, not when the mock is defined. Combined with the appropriate assert (toHaveBeenCalled), this gives us a guarantee that we went "onto the wire" and called that specific mutation with those specific variables.
We also test error handling by providing error responses in our mocks:
test("calls onError when user creation fails", async () => {
const onError = vitest.fn();
render(
<MockedProvider
mocks={[
{
request: {
query: CREATE_USER_MUTATION,
variables: {
/* ... */
},
},
error: new Error("User creation failed"),
},
]}
>
<CreateUserDialog onError={onError} />
</MockedProvider>
);
// Submit form and verify error callback
await userEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith(new Error("User creation failed"))
);
});The Users component refetches data after successful user creation by calling the refetch() function from Apollo's useQuery hook:
<CreateUserDialog
isOpen={isCreateUserDialogOpen}
onClose={() => setIsCreateUserDialogOpen(false)}
onSuccess={() => {
setIsCreateUserDialogOpen(false);
refetch(); // This triggers a new query execution
}}
onError={(error) => {
console.error(error);
}}
/>You could also use refetch sets if it makes sense in your scenario and this testing approach will work the same.
However, if the dialog here was doing direct Apollo cache modification, this strategy would not work. Testing cache modification becomes more complicated; reliant on Apollo; and impossible to test across dependent components like this. This is why I tend to avoid that strategy and stick with simple, boring refetch() if possible.
We test this by providing multiple mock responses for the same query:
test("refetches users after creating a user", async () => {
render(
<MockedProvider
mocks={[
// Initial query returns no users
{
request: { query: USERS_QUERY },
result: { data: { users: [] } },
},
// Refetch query returns a user
{
request: { query: USERS_QUERY },
result: { data: { users: [{ id: "1", name: "John Doe" }] } },
},
]}
>
<Users />
</MockedProvider>
);
// Initially shows "No users"
expect(await screen.findByText("No users")).toBeVisible();
// Open dialog and create user
await userEvent.click(screen.getByRole("button", { name: "Create User" }));
await userEvent.click(screen.getByTestId("mock-create-user-button"));
// Verify refetch occurred and new data is displayed
expect(await screen.findByText("John Doe")).toBeVisible();
});This approach ensures that:
- The initial query executes correctly
- The refetch is triggered after user creation
- The UI updates with the new data
- The dialog closes properly