Apollo Client Testing: Mocking Queries, Mutations, and Cache
Apollo Client is the most widely used GraphQL client for React, and testing it properly means going beyond shallow component tests. You need to verify that your components handle loading states, successful data responses, error states, and cache interactions correctly. Apollo provides MockedProvider for exactly this purpose — this guide shows you how to use it well.
Understanding MockedProvider
MockedProvider is Apollo's built-in test utility that intercepts GraphQL operations and returns mock responses instead of making real network requests. It accepts an array of mocks, where each mock defines an operation (query or mutation) and its result.
npm install --save-dev @apollo/client @testing-library/react @testing-library/jest-domBasic Query Mocking
Start with the simplest case — mocking a query response:
import { MockedProvider } from '@apollo/client/testing';
import { render, screen, waitFor } from '@testing-library/react';
import { gql } from '@apollo/client';
import UserProfile from '../components/UserProfile';
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
role
}
}
`;
const mocks = [
{
request: {
query: GET_USER,
variables: { id: '1' },
},
result: {
data: {
user: {
id: '1',
name: 'Jane Smith',
email: 'jane@example.com',
role: 'ADMIN',
},
},
},
},
];
test('renders user profile data', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
);
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Data loaded
await waitFor(() => {
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
expect(screen.getByText('ADMIN')).toBeInTheDocument();
});Note: addTypename={false} prevents Apollo from adding __typename to every query — which would break your mocks if they don't include it.
Testing Loading States
Always test the loading state explicitly:
test('shows loading spinner while query is in flight', () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
);
// Don't await — check the initial render synchronously
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument();
});Testing Error States
Test both GraphQL errors and network errors:
const errorMocks = [
{
request: {
query: GET_USER,
variables: { id: '999' },
},
result: {
errors: [{ message: 'User not found' }],
},
},
];
test('shows error message when query fails', async () => {
render(
<MockedProvider mocks={errorMocks} addTypename={false}>
<UserProfile userId="999" />
</MockedProvider>
);
await waitFor(() => {
expect(screen.getByText('User not found')).toBeInTheDocument();
});
});
// Network error (server unreachable)
import { ApolloError } from '@apollo/client';
const networkErrorMocks = [
{
request: {
query: GET_USER,
variables: { id: '1' },
},
error: new Error('Network request failed'),
},
];
test('shows network error message', async () => {
render(
<MockedProvider mocks={networkErrorMocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
);
await waitFor(() => {
expect(screen.getByText('Network error. Please try again.')).toBeInTheDocument();
});
});Testing Mutations
Mutations require testing the trigger action, optimistic UI, and final state:
const UPDATE_USER = gql`
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
}
}
`;
const mutationMocks = [
{
request: {
query: GET_USER,
variables: { id: '1' },
},
result: {
data: {
user: { id: '1', name: 'Jane Smith', email: 'jane@example.com', role: 'ADMIN' },
},
},
},
{
request: {
query: UPDATE_USER,
variables: { id: '1', input: { name: 'Jane Doe', email: 'jane@example.com' } },
},
result: {
data: {
updateUser: { id: '1', name: 'Jane Doe', email: 'jane@example.com' },
},
},
},
];
test('updates user name on form submit', async () => {
const user = userEvent.setup();
render(
<MockedProvider mocks={mutationMocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
);
// Wait for initial data
await waitFor(() => screen.getByText('Jane Smith'));
// Open edit form
await user.click(screen.getByRole('button', { name: 'Edit' }));
// Change name
const nameInput = screen.getByLabelText('Name');
await user.clear(nameInput);
await user.type(nameInput, 'Jane Doe');
// Submit
await user.click(screen.getByRole('button', { name: 'Save' }));
// Verify update applied
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
});Testing Cache Interactions
Apollo's cache is one of its most powerful features — and one of the most common sources of bugs:
import { InMemoryCache } from '@apollo/client';
test('query reads from cache on second render', async () => {
const cache = new InMemoryCache();
// Pre-populate the cache
cache.writeQuery({
query: GET_USER,
variables: { id: '1' },
data: {
user: { id: '1', name: 'Cached User', email: 'cached@example.com', role: 'USER' },
},
});
render(
<MockedProvider mocks={[]} addTypename={false} cache={cache}>
<UserProfile userId="1" />
</MockedProvider>
);
// Should render immediately without a loading state (data from cache)
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.getByText('Cached User')).toBeInTheDocument();
});
test('mutation updates cache correctly', async () => {
const cache = new InMemoryCache();
const user = userEvent.setup();
render(
<MockedProvider mocks={mutationMocks} addTypename={false} cache={cache}>
<UserProfile userId="1" />
</MockedProvider>
);
await waitFor(() => screen.getByText('Jane Smith'));
await user.click(screen.getByRole('button', { name: 'Edit' }));
await user.clear(screen.getByLabelText('Name'));
await user.type(screen.getByLabelText('Name'), 'Jane Doe');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => screen.getByText('Jane Doe'));
// Verify cache was updated
const cachedUser = cache.readQuery({
query: GET_USER,
variables: { id: '1' },
});
expect(cachedUser.user.name).toBe('Jane Doe');
});Testing Optimistic UI
Optimistic updates make your UI feel instant. Test that the optimistic state appears before the server responds:
test('shows optimistic name update before server confirms', async () => {
const user = userEvent.setup();
let resolveUpdate;
const slowMutationMock = {
request: {
query: UPDATE_USER,
variables: { id: '1', input: { name: 'Jane Doe', email: 'jane@example.com' } },
},
result: () => new Promise(resolve => {
resolveUpdate = () => resolve({
data: { updateUser: { id: '1', name: 'Jane Doe', email: 'jane@example.com' } },
});
}),
};
render(
<MockedProvider mocks={[mocks[0], slowMutationMock]} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
);
await waitFor(() => screen.getByText('Jane Smith'));
await user.click(screen.getByRole('button', { name: 'Edit' }));
await user.clear(screen.getByLabelText('Name'));
await user.type(screen.getByLabelText('Name'), 'Jane Doe');
await user.click(screen.getByRole('button', { name: 'Save' }));
// Optimistic update should show immediately
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
// Now resolve the mutation
resolveUpdate();
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
});Testing Pagination with fetchMore
fetchMore is commonly used for load-more patterns — test that it works correctly:
const GET_POSTS = gql`
query GetPosts($cursor: String, $limit: Int!) {
posts(cursor: $cursor, limit: $limit) {
edges {
node { id title }
cursor
}
pageInfo { hasNextPage endCursor }
}
}
`;
const paginationMocks = [
{
request: { query: GET_POSTS, variables: { cursor: null, limit: 10 } },
result: {
data: {
posts: {
edges: Array.from({ length: 10 }, (_, i) => ({
node: { id: String(i + 1), title: `Post ${i + 1}` },
cursor: `cursor-${i + 1}`,
})),
pageInfo: { hasNextPage: true, endCursor: 'cursor-10' },
},
},
},
},
{
request: { query: GET_POSTS, variables: { cursor: 'cursor-10', limit: 10 } },
result: {
data: {
posts: {
edges: Array.from({ length: 5 }, (_, i) => ({
node: { id: String(i + 11), title: `Post ${i + 11}` },
cursor: `cursor-${i + 11}`,
})),
pageInfo: { hasNextPage: false, endCursor: 'cursor-15' },
},
},
},
},
];
test('loads more posts on button click', async () => {
const user = userEvent.setup();
render(
<MockedProvider mocks={paginationMocks} addTypename={false}>
<PostList />
</MockedProvider>
);
await waitFor(() => screen.getByText('Post 1'));
expect(screen.getAllByRole('article')).toHaveLength(10);
await user.click(screen.getByRole('button', { name: 'Load More' }));
await waitFor(() => {
expect(screen.getAllByRole('article')).toHaveLength(15);
});
expect(screen.queryByRole('button', { name: 'Load More' })).not.toBeInTheDocument();
});E2E Testing with HelpMeTest
Unit tests with MockedProvider cover component logic, but production GraphQL behavior needs E2E monitoring. HelpMeTest runs browser-level tests against your live API:
Navigate to https://your-app.com/users/1
Verify user profile loads within 2 seconds
Verify user name is displayed
Click Edit button
Change user name to "Test User"
Click Save
Verify success toast appears
Verify profile displays updated nameHelpMeTest monitors these flows 24/7, catching Apollo cache invalidation bugs, server-side GraphQL schema changes, and network regressions before users report them.
Common Pitfalls
Mock order matters. MockedProvider consumes mocks in order. If your component fires the same query twice, add the mock twice.
Variables must match exactly. If your mock has variables: { id: '1' } but your component sends { id: 1 } (number), the mock won't match and you'll see a No more mocked responses error.
Don't forget addTypename={false}. Without it, Apollo adds __typename to every field selection. Your mocks must then include __typename on every nested object, which is tedious and brittle.
Use act() for mutations. Mutation handlers that update state need to be wrapped in act(). React Testing Library's userEvent handles this, but manual trigger logic might not.
Summary
Testing Apollo Client well requires:
MockedProviderfor controlled query and mutation responses- Explicit loading, success, and error state tests
- Cache pre-population and post-mutation cache verification
- Optimistic UI testing with delayed mock responses
- Pagination testing with sequential
fetchMoremocks - E2E monitoring with HelpMeTest for production GraphQL health