Testing GraphQL with Jest and Apollo Client: A Complete Guide

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:

  • MockedProvider for component tests — fast, isolated, covers loading/error/success states
  • renderHook for 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.

Read more

Start now free