Testing Apollo Components: From Unit to E2E Tests
Building applications with Apollo Client gives you powerful data-fetching capabilities, but with great power comes the responsibility of ensuring your components behave correctly under every possible state — loading, success, error, and partial data. This tutorial walks you through a complete testing strategy for Apollo-powered React components, moving from isolated unit tests all the way up to end-to-end tests that exercise your full stack.
What Is Apollo Component Testing?
Apollo component testing is the practice of verifying that React components which rely on Apollo Client for GraphQL data render and behave correctly. Because Apollo components interact with a GraphQL server through queries and mutations, testing them involves simulating those interactions without necessarily hitting a real backend. The official @apollo/client/testing package provides a MockedProvider component that intercepts GraphQL operations and returns predetermined responses, making it possible to test components in isolation.
A robust testing strategy for Apollo components typically spans three layers:
- Unit tests — Verify individual components render correctly for each Apollo state (loading, success, error) using mocked GraphQL responses.
- Integration tests — Verify multiple components work together, including cache behavior, mutations updating the cache, and refetch logic.
- End-to-end (E2E) tests — Verify the entire application flow against a real or staging GraphQL server, ensuring the UI and backend integrate correctly.
Why Testing Apollo Components Matters
Apollo components are inherently asynchronous and stateful. A single query can produce loading spinners, populated views, error messages, or empty states. Without tests, it is easy to ship components that crash on edge cases such as nullable fields, network failures, or partial cache hits. Testing also guards against regressions when you refactor queries, change cache policies, or upgrade Apollo Client versions.
Furthermore, GraphQL schemas evolve. Fields become deprecated, types change, and new required arguments appear. A good test suite catches these mismatches early by asserting that your components handle the exact shapes of data they expect. Finally, E2E tests give you confidence that the entire pipeline — from the React tree through Apollo Client to the GraphQL resolver and database — works as intended before users ever see it.
Setting Up the Testing Environment
Before writing tests, install the necessary dependencies. This tutorial assumes you use Jest as the test runner and React Testing Library for rendering components.
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @apollo/client graphql
Create a Jest setup file to configure the DOM environment and add custom matchers from @testing-library/jest-dom.
// jest.setup.js
import '@testing-library/jest-dom';
// Mock matchMedia if your components use responsive logic
window.matchMedia = window.matchMedia || function () {
return {
matches: false,
addListener: jest.fn(),
removeListener: jest.fn(),
};
};
Update your Jest configuration to use the setup file and the jsdom environment.
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
};
Writing Unit Tests with MockedProvider
The MockedProvider is the cornerstone of Apollo unit testing. It wraps your component tree and intercepts every GraphQL operation, returning the mock responses you define. Let us start with a simple component that fetches a list of users.
The Component Under Test
// src/components/UserList.jsx
import React from 'react';
import { useQuery, gql } from '@apollo/client';
export const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
export function UserList() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p role="status">Loading users...</p>;
if (error) return <p role="alert">Error loading users</p>;
return (
<ul aria-label="user-list">
{data.users.map((user) => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}
Testing the Loading State
The first test verifies that the component displays a loading indicator while the query is in flight. Because MockedProvider resolves mocks asynchronously, the loading state is visible on the initial render.
// src/components/UserList.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { UserList, GET_USERS } from './UserList';
const mocks = [
{
request: {
query: GET_USERS,
},
result: {
data: {
users: [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
],
},
},
},
];
describe('UserList', () => {
it('shows a loading indicator initially', () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserList />
</MockedProvider>
);
expect(screen.getByRole('status')).toHaveTextContent('Loading users...');
});
});
Note the addTypename={false} prop. Apollo Client automatically adds a __typename field to queries. When your mock data does not include __typename, you must disable this behavior to avoid warnings and mismatches. Alternatively, include __typename in every mock object.
Testing the Success State
To test the populated state, wait for the query to resolve using findBy queries, which retry until the element appears or the test times out.
import { render, screen, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { UserList, GET_USERS } from './UserList';
it('renders the list of users after the query resolves', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserList />
</MockedProvider>
);
const list = await screen.findByLabelText('user-list');
expect(list).toBeInTheDocument();
expect(screen.getByText('Alice — alice@example.com')).toBeInTheDocument();
expect(screen.getByText('Bob — bob@example.com')).toBeInTheDocument();
});
Testing the Error State
Errors are just as important as success cases. Provide an error property instead of result in the mock to simulate a GraphQL or network failure.
const errorMocks = [
{
request: {
query: GET_USERS,
},
error: new Error('Network error: failed to fetch users'),
},
];
it('displays an error message when the query fails', async () => {
render(
<MockedProvider mocks={errorMocks} addTypename={false}>
<UserList />
</MockedProvider>
);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Error loading users');
});
Testing the Empty State
Do not forget the case where the query succeeds but returns an empty array. This is a common source of bugs when components assume data is always present.
const emptyMocks = [
{
request: { query: GET_USERS },
result: { data: { users: [] } },
},
];
it('renders an empty list when no users exist', async () => {
render(
<MockedProvider mocks={emptyMocks} addTypename={false}>
<UserList />
</MockedProvider>
);
const list = await screen.findByLabelText('user-list');
expect(list).toBeEmptyDOMElement();
});
Testing Mutations
Mutations require a different approach because they are triggered by user interaction rather than automatically on mount. Let us create a component that adds a new user.
// src/components/AddUser.jsx
import React, { useState } from 'react';
import { useMutation, gql } from '@apollo/client';
export const ADD_USER = gql`
mutation AddUser($name: String!, $email: String!) {
addUser(name: $name, email: $email) {
id
name
email
}
}
`;
export function AddUser() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [addUser, { loading, error }] = useMutation(ADD_USER);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await addUser({ variables: { name, email } });
setName('');
setEmail('');
} catch (err) {
// Error is available via the error state
}
};
return (
<form onSubmit={handleSubmit}>
<input
aria-label="name-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
aria-label="email-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<button type="submit" disabled={loading}>
{loading ? 'Adding...' : 'Add User'}
</button>
{error && <p role="alert">Failed to add user</p>}
</form>
);
}
The test simulates typing into the inputs, submitting the form, and asserting that the mutation was called with the correct variables and that the UI reflects the result.
// src/components/AddUser.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MockedProvider } from '@apollo/client/testing';
import { AddUser, ADD_USER } from './AddUser';
const mutationMocks = [
{
request: {
query: ADD_USER,
variables: { name: 'Charlie', email: 'charlie@example.com' },
},
result: {
data: {
addUser: { id: '3', name: 'Charlie', email: 'charlie@example.com' },
},
},
},
];
describe('AddUser', () => {
it('submits the form and calls the mutation', async () => {
const user = userEvent.setup();
render(
<MockedProvider mocks={mutationMocks} addTypename={false}>
<AddUser />
</MockedProvider>
);
await user.type(screen.getByLabelText('name-input'), 'Charlie');
await user.type(screen.getByLabelText('email-input'), 'charlie@example.com');
await user.click(screen.getByRole('button', { name: 'Add User' }));
// The button should show the loading state briefly
expect(await screen.findByRole('button', { name: 'Add User' })).toBeInTheDocument();
// Inputs should be cleared after a successful submission
expect(screen.getByLabelText('name-input')).toHaveValue('');
expect(screen.getByLabelText('email-input')).toHaveValue('');
});
it('displays an error when the mutation fails', async () => {
const user = userEvent.setup();
const errorMocks = [
{
request: {
query: ADD_USER,
variables: { name: 'Charlie', email: 'charlie@example.com' },
},
error: new Error('Email already exists'),
},
];
render(
<MockedProvider mocks={errorMocks} addTypename={false}>
<AddUser />
</MockedProvider>
);
await user.type(screen.getByLabelText('name-input'), 'Charlie');
await user.type(screen.getByLabelText('email-input'), 'charlie@example.com');
await user.click(screen.getByRole('button', { name: 'Add User' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to add user');
});
});
A critical detail here is that the mock variables must match exactly what the component sends. If the variables differ even slightly — for example, an extra property or a different string — MockedProvider will not find a matching mock and the mutation will hang. This strict matching is intentional and helps catch bugs, but it can be a source of confusion when tests time out unexpectedly.
Testing Cache Updates and Optimistic UI
Apollo Client's normalized cache is one of its most powerful features, and it deserves its own tests. Suppose you want the AddUser mutation to automatically update the UserList cache. You can test this by combining both components inside a single MockedProvider.
// src/components/AddUserWithCache.jsx
import React, { useState } from 'react';
import { useMutation, gql } from '@apollo/client';
import { GET_USERS } from './UserList';
export const ADD_USER_WITH_CACHE = gql`
mutation AddUserWithCache($name: String!, $email: String!) {
addUser(name: $name, email: $email) {
id
name
email
}
}
`;
export function AddUserWithCache() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [addUser] = useMutation(ADD_USER_WITH_CACHE, {
update(cache, { data: { addUser } }) {
const { users } = cache.readQuery({ query: GET_USERS });
cache.writeQuery({
query: GET_USERS,
data: { users: [...users, addUser] },
});
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
addUser({ variables: { name, email } });
setName('');
setEmail('');
}}
>
<input aria-label="name-input" value={name} onChange={(e) => setName(e.target.value)} />
<input aria-label="email-input" value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Add User</button>
</form>
);
}
// src/components/Integration.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MockedProvider } from '@apollo/client/testing';
import { UserList, GET_USERS } from './UserList';
import { AddUserWithCache, ADD_USER_WITH_CACHE } from './AddUserWithCache';
const integrationMocks = [
{
request: { query: GET_USERS },
result: {
data: {
users: [{ id: '1', name: 'Alice', email: 'alice@example.com' }],
},
},
},
{
request: {
query: ADD_USER_WITH_CACHE,
variables: { name: 'Bob', email: 'bob@example.com' },
},
result: {
data: {
addUser: { id: '2', name: 'Bob', email: 'bob@example.com' },
},
},
},
];
describe('UserList and AddUser integration', () => {
it('updates the list after adding a user via cache', async () => {
const user = userEvent.setup();
render(
<MockedProvider mocks={integrationMocks} addTypename={false}>
<div>
<UserList />
<AddUserWithCache />
</div>
</MockedProvider>
);
// Wait for the initial query to resolve
expect(await screen.findByText('Alice — alice@example.com')).toBeInTheDocument();
// Add a new user
await user.type(screen.getByLabelText('name-input'), 'Bob');
await user.type(screen.getByLabelText('email-input'), 'bob@example.com');
await user.click(screen.getByRole('button', { name: 'Add User' }));
// The new user should appear without a refetch
expect(await screen.findByText('Bob — bob@example.com')).toBeInTheDocument();
expect(screen.getByText('Alice — alice@example.com')).toBeInTheDocument();
});
});
This test demonstrates that the cache update logic in the mutation's update function works correctly. If the update function has a bug — for instance, it overwrites the list instead of appending — the test will fail because Bob will not appear alongside Alice.
Testing with Variables and Pagination
Real-world queries often include variables, and testing them requires matching those variables in the mock. Consider a component that fetches users with a limit and offset for pagination.
// src/components/PaginatedUserList.jsx
import React, { useState } from 'react';
import { useQuery, gql } from '@apollo/client';
export const GET_PAGINATED_USERS = gql`
query GetPaginatedUsers($limit: Int!, $offset: Int!) {
users(limit: $limit, offset: $offset) {
id
name
}
}
`;
export function PaginatedUserList() {
const [offset, setOffset] = useState(0);
const { loading, error, data } = useQuery(GET_PAGINATED_USERS, {
variables: { limit: 2, offset },
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error</p>;
return (
<div>
<ul aria-label="paginated-list">
{data.users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
<button onClick={() => setOffset(offset + 2)}>Next Page</button>
</div>
);
}
// src/components/PaginatedUserList.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MockedProvider } from '@apollo/client/testing';
import { PaginatedUserList, GET_PAGINATED_USERS } from './PaginatedUserList';
const paginationMocks = [
{
request: {
query: GET_PAGINATED_USERS,
variables: { limit: 2, offset: 0 },
},
result: {
data: {
users: [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
],
},
},
},
{
request: {
query: GET_PAGINATED_USERS,
variables: { limit: 2, offset: 2 },
},
result: {
data: {
users: [
{ id: '3', name: 'Charlie' },
{ id: '4', name: 'Diana' },
],
},
},
},
];
describe('PaginatedUserList', () => {
it('loads the next page when the button is clicked', async () => {
const user = userEvent.setup();
render(
<MockedProvider mocks={paginationMocks} addTypename={false}>
<PaginatedUserList />
</MockedProvider>
);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
await user.click(screen.getByText('Next Page'));
expect(await screen.findByText('Charlie')).toBeInTheDocument();
expect(screen.getByText('Diana')).toBeInTheDocument();
});
});
Testing Subscriptions
Subscriptions add real-time data to your application. Testing them with MockedProvider involves providing an initial result and then pushing additional results through the mock's result as a function or using the newData callback. Here is a simplified example.
// src/components/LiveCounter.jsx
import React from 'react';
import { useSubscription, gql } from '@apollo/client';
export const COUNTER_SUBSCRIPTION = gql`
subscription OnCountUpdated {
countUpdated
}
`;
export function LiveCounter() {
const { data, loading } = useSubscription(COUNTER_SUBSCRIPTION);
if (loading) return <p>Waiting for updates...</p>;
return <p aria-label="counter">Count: {data.countUpdated}</p>;
}
// src/components/LiveCounter.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { LiveCounter, COUNTER_SUBSCRIPTION } from './LiveCounter';
const subscriptionMocks = [
{
request: { query: COUNTER_SUBSCRIPTION },
result: { data: { countUpdated: 1 } },
},
{
request: { query: COUNTER_SUBSCRIPTION },
result: { data: { countUpdated: 2 } },
},
];
describe('LiveCounter', () => {
it('displays the initial subscription value', async () => {
render(
<MockedProvider mocks={subscriptionMocks} addTypename={false}>
<LiveCounter />
</MockedProvider>
);
expect(await screen.findByLabelText('counter')).toHaveTextContent('Count: 1');
});
});
For more complex subscription scenarios, consider using the newData option or a custom link that emits events on a schedule. The key principle remains the same: control the data flow so your tests are deterministic.
End-to-End Testing with Playwright
Unit and integration tests with MockedProvider are fast and reliable, but they do not test the real GraphQL server. End-to-end tests fill that gap by driving a browser against your running application. Playwright is an excellent choice for E2E testing because it supports multiple browsers and has a clean API.
First, install Playwright.
npm install --save-dev @playwright/test
npx playwright install
Create a Playwright configuration file.
// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
headless: true,
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
});
Now write an E2E test that exercises the full user flow: loading the user list, adding a new user, and verifying the new user appears.
// e2e/userFlow.spec.js
const { test, expect } = require('@playwright/test');
test('user can view and add users', async ({ page }) => {
await page.goto('/');
// Wait for the user list to load
const userList = page.getByLabel('user-list');
await expect(userList).toBeVisible();
// Verify at least one user is displayed
const firstUser = userList.locator('li').first();
await expect(firstUser).toBeVisible();
// Add a new user
const uniqueName = `E2E User ${Date.now()}`;
const uniqueEmail = `e2e-${Date.now()}@example.com`;
await page.getByLabel('name-input').fill(uniqueName);
await page.getByLabel('email-input').fill(uniqueEmail);
await page.getByRole('button', { name: 'Add User' }).click();
// Verify the new user appears in the list
await expect(page.getByText(`${uniqueName} — ${uniqueEmail}`)).toBeVisible();
});
This test runs against your real application and real GraphQL server. It will catch issues that mocked tests cannot, such as schema mismatches, authentication problems, resolver bugs, and CORS configuration errors.
Intercepting GraphQL Requests in E2E Tests
Sometimes you want E2E-level browser interaction but still need deterministic data. Playwright lets you intercept network requests and return mock responses, giving you the best of both worlds.
// e2e/userFlowMocked.spec.js
const { test, expect } = require('@playwright/test');
test('displays mocked users in the list', async ({ page }) => {
await page.route('**/graphql', async (route) => {
const request = route.request();
const postData = request.postDataJSON();
if (postData.operationName === 'GetUsers') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
users: [
{ id: '1', name: 'Mocked Alice', email: 'mocked-alice@example.com' },
],
},
}),
});
} else {
await route.continue();
}
});
await page.goto('/');
const userList = page.getByLabel('user-list');
await expect(userList).toBeVisible();
await expect(page.getByText('Mocked Alice — mocked-alice@example.com')).toBeVisible();
});
This approach is useful when your staging server is unreliable or when you need to test edge cases that are difficult to reproduce with real data. However, rely on real-server E2E tests for your primary confidence — mocked E2E tests are a supplement, not a replacement.
Best Practices
- Always test all four states: loading, success, error, and empty. Skipping any of these leaves a gap that bugs will find.
- Match mock variables exactly.
MockedProviderrequires an exact match on query and variables. If your test hangs, the most common cause is a variable mismatch. - Use
addTypename={false}consistently or include__typenamein every mock. Mixing the two approaches leads to confusing cache warnings. - Prefer
findByovergetByfor async content.findBywaits for the element to appear, which is essential after Apollo resolves a query. - Test cache updates explicitly. Do not assume the cache works correctly — write integration tests that verify mutations update the UI without a full refetch.
- Keep E2E tests focused and fast. Each E2E test should cover one user journey. Too many assertions per test makes them slow and brittle.
- Use a dedicated test database for E2E tests. Never run E2E tests against your production database. Reset the database between runs to keep tests deterministic.
- Mock at the network layer for E2E edge cases. Use Playwright's
page.routeto simulate errors, slow responses, or unusual payloads that are hard to trigger against a real server. - Extract shared mocks into fixtures. If multiple tests use the same mock data, move it to a shared file to reduce duplication and make schema changes easier to manage.
- Test error recovery. Verify that users can retry after a failure, not just that the error message appears. A good UX guides users back to a working state.
Conclusion
Testing Apollo components effectively requires thinking in terms of GraphQL states: every query and mutation can succeed, fail, load, or return empty data, and your test suite should cover all of these paths. By combining MockedProvider for fast, deterministic unit and integration tests with Playwright for real-world E2E validation, you build a safety net that catches regressions at every layer of your application. Start with the four-state pattern for every query component, add integration tests for cache updates, and layer in E2E tests for your most critical user journeys. This layered approach gives you both speed and confidence, ensuring your Apollo-powered application remains reliable as it grows and evolves.