Testing GraphQL with Jest and Apollo Client: A Complete Guide
Apollo Client is the dominant GraphQL client in the JavaScript ecosystem, and Jest is the dominant test runner. Using them together should be straightforward — but Apollo's caching model, network layer abstraction, and hook-based API introduce complexity that bites teams at test time.
This guide covers the full testing stack: mocking Apollo operations in component tests, testing Apollo hooks in isolation, testing the cache, and writing integration tests that hit a real Apollo Server.
The Apollo Client Testing Stack
Apollo provides @apollo/client/testing with MockedProvider — a test wrapper that intercepts GraphQL operations and returns mock responses without any network calls. This is the foundation of component-level testing.
For hook-level testing, @testing-library/react-hooks (or the modern renderHook from @testing-library/react) works well with MockedProvider.
For integration tests, you'll run a real Apollo Server in the test process and point ApolloClient at it.
MockedProvider Basics
import { render, screen, waitFor } from '@testing-library/react'
import { MockedProvider } from '@apollo/client/testing'
import { gql } from '@apollo/client'
import { UserProfile } from './UserProfile'
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
avatar
}
}
`
const mocks = [
{
request: {
query: GET_USER,
variables: { id: '1' },
},
result: {
data: {
user: {
id: '1',
name: 'Alice',
email: 'alice@example.com',
avatar: 'https://example.com/alice.jpg',
},
},
},
},
]
describe('UserProfile', () => {
it('renders user data after loading', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
)
// Initial loading state
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
})The addTypename Gotcha
By default, MockedProvider adds __typename to every query. If your components don't include __typename in their query documents, the mock matching will fail silently — you'll get the loading state forever.
Two solutions:
Option 1: Set addTypename={false} in MockedProvider (shown above). Simpler, but loses typename-based cache normalization in tests.
Option 2: Add __typename to your mock responses:
const mocks = [
{
request: { query: GET_USER, variables: { id: '1' } },
result: {
data: {
user: {
__typename: 'User',
id: '1',
name: 'Alice',
email: 'alice@example.com',
avatar: 'https://example.com/alice.jpg',
},
},
},
},
]Option 2 is more correct for testing real behavior, but requires keeping typenames in sync with your schema.
Testing Loading, Error, and Empty States
Component tests should cover all three states your UI renders:
describe('UserProfile states', () => {
it('shows loading spinner initially', () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
)
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
})
it('shows error state on network error', async () => {
const errorMocks = [
{
request: { query: GET_USER, variables: { id: '1' } },
error: new Error('Network error'),
},
]
render(
<MockedProvider mocks={errorMocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
)
await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
})
})
it('shows GraphQL errors from server', async () => {
const graphqlErrorMocks = [
{
request: { query: GET_USER, variables: { id: 'nonexistent' } },
result: {
errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
},
},
]
render(
<MockedProvider mocks={graphqlErrorMocks} addTypename={false}>
<UserProfile userId="nonexistent" />
</MockedProvider>
)
await waitFor(() => {
expect(screen.getByText(/user not found/i)).toBeInTheDocument()
})
})
it('shows empty state when user has no posts', async () => {
const emptyMocks = [
{
request: { query: GET_USER_WITH_POSTS, variables: { id: '1' } },
result: {
data: {
user: { id: '1', name: 'Alice', posts: [] },
},
},
},
]
render(
<MockedProvider mocks={emptyMocks} addTypename={false}>
<UserPostList userId="1" />
</MockedProvider>
)
await waitFor(() => {
expect(screen.getByText(/no posts yet/i)).toBeInTheDocument()
})
})
})Testing Mutations
Mutations require testing both the trigger action and the response handling:
const CREATE_POST = gql`
mutation CreatePost($title: String!, $body: String!) {
createPost(title: $title, body: $body) {
id
title
createdAt
}
}
`
const createPostMocks = [
{
request: {
query: CREATE_POST,
variables: { title: 'My Post', body: 'Content here' },
},
result: {
data: {
createPost: {
id: '99',
title: 'My Post',
createdAt: '2026-01-01T00:00:00Z',
},
},
},
},
]
describe('CreatePostForm', () => {
it('submits mutation and shows success', async () => {
const { getByLabelText, getByRole } = render(
<MockedProvider mocks={createPostMocks} addTypename={false}>
<CreatePostForm />
</MockedProvider>
)
await userEvent.type(getByLabelText('Title'), 'My Post')
await userEvent.type(getByLabelText('Body'), 'Content here')
await userEvent.click(getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(screen.getByText(/post created/i)).toBeInTheDocument()
})
})
it('shows mutation loading state during submission', async () => {
// Delay the mock response to see the loading state
const delayedMocks = [
{
request: {
query: CREATE_POST,
variables: { title: 'My Post', body: 'Content here' },
},
result: { data: { createPost: { id: '99', title: 'My Post', createdAt: '2026-01-01' } } },
delay: 100,
},
]
const { getByLabelText, getByRole } = render(
<MockedProvider mocks={delayedMocks} addTypename={false}>
<CreatePostForm />
</MockedProvider>
)
await userEvent.type(getByLabelText('Title'), 'My Post')
await userEvent.type(getByLabelText('Body'), 'Content here')
await userEvent.click(getByRole('button', { name: /submit/i }))
// Button should be disabled while loading
expect(getByRole('button', { name: /submit/i })).toBeDisabled()
await waitFor(() => {
expect(screen.getByText(/post created/i)).toBeInTheDocument()
})
})
})Testing Apollo Hooks Directly
When business logic lives in custom hooks that wrap useQuery or useMutation, test the hook in isolation:
import { renderHook, act } from '@testing-library/react'
import { MockedProvider } from '@apollo/client/testing'
import { useUserData } from './useUserData'
function createWrapper(mocks) {
return function Wrapper({ children }) {
return (
<MockedProvider mocks={mocks} addTypename={false}>
{children}
</MockedProvider>
)
}
}
describe('useUserData hook', () => {
it('returns loading state initially', () => {
const { result } = renderHook(() => useUserData('1'), {
wrapper: createWrapper(mocks),
})
expect(result.current.loading).toBe(true)
expect(result.current.user).toBeUndefined()
})
it('returns user data after query resolves', async () => {
const { result } = renderHook(() => useUserData('1'), {
wrapper: createWrapper(mocks),
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.user).toMatchObject({
id: '1',
name: 'Alice',
})
})
it('refetch retrieves updated data', async () => {
const refetchMocks = [
...mocks,
{
request: { query: GET_USER, variables: { id: '1' } },
result: {
data: { user: { id: '1', name: 'Alice Updated', email: 'alice@example.com' } },
},
},
]
const { result } = renderHook(() => useUserData('1'), {
wrapper: createWrapper(refetchMocks),
})
await waitFor(() => expect(result.current.loading).toBe(false))
await act(async () => {
await result.current.refetch()
})
expect(result.current.user.name).toBe('Alice Updated')
})
})Testing Apollo Cache
Apollo's normalized cache is one of its most powerful features — and one of the most common sources of subtle bugs. Test cache behavior directly.
import { ApolloClient, InMemoryCache, gql } from '@apollo/client'
describe('Apollo cache normalization', () => {
let client
beforeEach(() => {
client = new ApolloClient({
cache: new InMemoryCache(),
uri: 'http://localhost:4000/graphql',
})
})
it('writes and reads from cache correctly', () => {
client.writeQuery({
query: GET_USER,
variables: { id: '1' },
data: {
user: { __typename: 'User', id: '1', name: 'Alice', email: 'alice@example.com' },
},
})
const cached = client.readQuery({
query: GET_USER,
variables: { id: '1' },
})
expect(cached.user.name).toBe('Alice')
})
it('normalizes entities so updates reflect everywhere', () => {
// Write user in one query context
client.writeQuery({
query: GET_USER,
variables: { id: '1' },
data: {
user: { __typename: 'User', id: '1', name: 'Alice', email: 'alice@example.com' },
},
})
// Update the user via cache directly (simulating mutation result)
client.writeFragment({
id: 'User:1',
fragment: gql`fragment UserName on User { name }`,
data: { name: 'Alice Smith' },
})
// Read via the original query — should reflect the update
const updated = client.readQuery({
query: GET_USER,
variables: { id: '1' },
})
expect(updated.user.name).toBe('Alice Smith')
})
it('evicts stale data correctly', () => {
client.writeQuery({
query: GET_USER,
variables: { id: '1' },
data: { user: { __typename: 'User', id: '1', name: 'Alice', email: 'alice@example.com' } },
})
client.cache.evict({ id: 'User:1' })
client.cache.gc()
const result = client.readQuery({
query: GET_USER,
variables: { id: '1' },
})
expect(result).toBeNull()
})
})Testing Optimistic Updates
Optimistic UI updates are one of Apollo's killer features. Test that they work correctly and that rollbacks happen on errors.
const LIKE_POST = gql`
mutation LikePost($postId: ID!) {
likePost(postId: $postId) {
id
likeCount
likedByMe
}
}
`
it('applies optimistic update immediately', async () => {
const { getByRole, getByText } = render(
<MockedProvider mocks={likeMocks} addTypename={false}>
<PostCard postId="10" initialLikes={5} />
</MockedProvider>
)
await waitFor(() => expect(getByText('5 likes')).toBeInTheDocument())
// Click like — optimistic update should be immediate
await userEvent.click(getByRole('button', { name: /like/i }))
// Optimistic update: 6 likes shown before server responds
expect(getByText('6 likes')).toBeInTheDocument()
// After server responds, still 6 likes
await waitFor(() => expect(getByText('6 likes')).toBeInTheDocument())
})
it('rolls back optimistic update on error', async () => {
const errorMocks = [
{
request: { query: LIKE_POST, variables: { postId: '10' } },
error: new Error('Server error'),
},
]
const { getByRole, getByText } = render(
<MockedProvider mocks={errorMocks} addTypename={false}>
<PostCard postId="10" initialLikes={5} />
</MockedProvider>
)
await waitFor(() => expect(getByText('5 likes')).toBeInTheDocument())
await userEvent.click(getByRole('button', { name: /like/i }))
// After error, rolled back to 5
await waitFor(() => expect(getByText('5 likes')).toBeInTheDocument())
})Integration Tests with Real Apollo Server
For end-to-end confidence, test against a real server in the test process:
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
import fetch from 'cross-fetch'
import { typeDefs } from '../schema'
import { resolvers } from '../resolvers'
import { createTestDb } from '../test-utils/db'
let server, url, client, db
beforeAll(async () => {
db = await createTestDb()
server = new ApolloServer({ typeDefs, resolvers, context: () => ({ db }) })
;({ url } = await startStandaloneServer(server, { listen: { port: 0 } }))
client = new ApolloClient({
link: new HttpLink({ uri: url, fetch }),
cache: new InMemoryCache(),
})
})
afterAll(async () => {
await server.stop()
await db.close()
})
describe('GraphQL integration', () => {
it('creates and retrieves a user', async () => {
const { data: createData } = await client.mutate({
mutation: gql`
mutation {
createUser(name: "Test User", email: "test@example.com") {
id name email
}
}
`,
})
const userId = createData.createUser.id
const { data: queryData } = await client.query({
query: gql`
query GetUser($id: ID!) {
user(id: $id) { id name email }
}
`,
variables: { id: userId },
fetchPolicy: 'network-only', // bypass cache
})
expect(queryData.user.name).toBe('Test User')
})
})Common Pitfalls
Variables must match exactly. MockedProvider matches mocks by deep-comparing request.query and request.variables. A single extra field or different variable casing will cause the mock to not fire — and you'll be stuck in loading state forever.
Mock order matters for repeated operations. If you call the same query twice, provide two mocks. The first call consumes the first mock, the second call consumes the second.
Cache causes test pollution. If you reuse an ApolloClient instance across tests, earlier tests can cache results that affect later ones. Use client.clearStore() in beforeEach or create a fresh client per test.
act() warnings mean async state updates. If you see "An update to component was not wrapped in act()", you have an unresolved async operation. Wrap trigger events in act() and use waitFor() to wait for the result.
Summary
Testing with Jest and Apollo Client breaks down into layers:
MockedProviderfor component tests — fast, isolated, covers loading/error/success statesrenderHookfor custom hook tests — tests business logic independent of UI- Cache testing with
writeQuery/readQuery— verifies normalization and updates - Real Apollo Server integration tests — full end-to-end confidence
The key discipline: always test all three states (loading, error, success), always verify mutation side effects, and always clear the cache between tests. Get those three right and Apollo test suites become reliable.