https://github.com/doytch/rtl-apollo-testing
Demonstrating approaches for testing React components that integrate with Apollo Client for GraphQL operations.
https://github.com/doytch/rtl-apollo-testing
apollo graphql react react-testing-library testing testing-library
Last synced: about 2 months ago
JSON representation
Demonstrating approaches for testing React components that integrate with Apollo Client for GraphQL operations.
- Host: GitHub
- URL: https://github.com/doytch/rtl-apollo-testing
- Owner: doytch
- License: mit
- Created: 2025-08-23T13:29:58.000Z (10 months ago)
- Default Branch: main
- Last Pushed: 2025-08-23T18:58:08.000Z (10 months ago)
- Last Synced: 2026-05-04T08:38:44.359Z (about 2 months ago)
- Topics: apollo, graphql, react, react-testing-library, testing, testing-library
- Language: TypeScript
- Homepage:
- Size: 44.9 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# React Testing Library + Apollo Client Testing
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.
## Table of Contents
- [Components Overview](#components-overview)
- [Drawing Seams with Mocking](#drawing-seams-with-mocking)
- [Testing Query Execution](#testing-query-execution)
- [Testing Mutation Execution](#testing-mutation-execution)
- [Testing Data Refetching](#testing-data-refetching)
- [Key Testing Principles](#key-testing-principles)
## Components Overview
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.
### Users Component
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
### CreateUserDialog Component
The `CreateUserDialog` component is a component that:
- Manages its own form state
- Handles the `CREATE_USER` mutation
- Provides success/error callbacks to parent components
## Drawing Seams with Mocking
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."](https://testing-library.com) 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.
### Mock Implementation
In `Users.test.tsx`, we mock the `CreateUserDialog` component:
```typescript
vitest.mock("./CreateUserDialog", () => ({
default: (props: ComponentProps) => {
if (!props.isOpen) return null;
return (
{
props.onSuccess({
id: "1",
name: "John Doe",
email: "john.doe@example.com",
role: "admin",
});
props.onClose();
}}
>
Create
);
},
}));
```
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.
## Testing Query Execution
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.
```typescript
test("renders users if there are users", async () => {
render(
);
expect(await screen.findByText("John Doe")).toBeVisible();
});
```
## Testing Mutation Execution
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']`:
```typescript
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: "john.doe@example.com",
role: "admin",
},
},
}));
render(
);
// ...
await waitFor(() => expect(onMutationResult).toHaveBeenCalled());
});
```
### Why Use Function Form?
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.
### Testing Error Scenarios
We also test error handling by providing error responses in our mocks:
```typescript
test("calls onError when user creation fails", async () => {
const onError = vitest.fn();
render(
);
// Submit form and verify error callback
await userEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith(new Error("User creation failed"))
);
});
```
## Testing Data Refetching
### Refetch Strategy
The `Users` component refetches data after successful user creation by calling the `refetch()` function from Apollo's `useQuery` hook:
```typescript
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.
### Testing Refetch Behavior
We test this by providing multiple mock responses for the same query:
```typescript
test("refetches users after creating a user", async () => {
render(
);
// 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:
1. The initial query executes correctly
2. The refetch is triggered after user creation
3. The UI updates with the new data
4. The dialog closes properly