Apollo Client Testing: Mocking Queries, Mutations, and Cache

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-dom

Basic 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 name

HelpMeTest 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:

  1. MockedProvider for controlled query and mutation responses
  2. Explicit loading, success, and error state tests
  3. Cache pre-population and post-mutation cache verification
  4. Optimistic UI testing with delayed mock responses
  5. Pagination testing with sequential fetchMore mocks
  6. E2E monitoring with HelpMeTest for production GraphQL health

Read more