Testing Apollo Client: Mocking Queries, Mutations, and Cache

Testing Apollo Client: Mocking Queries, Mutations, and Cache

Apollo Client components are hard to test without the right tools because they depend on a live GraphQL server. Apollo's MockedProvider lets you define expected operations and their responses, so you can test components in complete isolation. This guide covers the full spectrum: loading states, successful responses, errors, mutations, and cache interactions.

Key Takeaways

MockedProvider is your main tool. Wrap components with it and provide mocks arrays to control what Apollo returns for each operation.

Always await act(async () => {}) after renders. Apollo resolves queries asynchronously, so you need to flush the event loop before asserting on resolved data.

Test three states for every query: loading, success, error. Users see all three. All three should be verified.

Use cache option in MockedProvider to test cache reads and writes. Pass an InMemoryCache instance configured the same way as production.

Mutation mocks must include request AND result. Apollo matches mocks by deep-comparing the query document and variables.

Why Apollo Client Testing Is Tricky

Apollo Client hooks (useQuery, useMutation) are async, depend on a network layer, and interact with an in-memory cache. Testing components that use these hooks directly against a real server is slow and fragile.

@apollo/client/testing provides MockedProvider — a test-only Apollo provider that intercepts operations and returns predefined responses without touching the network.

Setup

npm install --save-dev @apollo/client @testing-library/react @testing-library/jest-dom
// jest.setup.js
import '@testing-library/jest-dom';

Basic Query Testing

The Component

// UserProfile.jsx
import { useQuery, gql } from '@apollo/client';

export const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`;

export function UserProfile({ userId }) {
  const { loading, error, data } = useQuery(GET_USER, {
    variables: { id: userId },
  });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>{data.user.name}</h1>
      <p>{data.user.email}</p>
    </div>
  );
}

Testing All Three States

import { render, screen, act } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { UserProfile, GET_USER } from './UserProfile';

const userMock = {
  request: {
    query: GET_USER,
    variables: { id: '1' },
  },
  result: {
    data: {
      user: {
        id: '1',
        name: 'Alice',
        email: 'alice@example.com',
      },
    },
  },
};

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    render(
      <MockedProvider mocks={[userMock]} addTypename={false}>
        <UserProfile userId="1" />
      </MockedProvider>
    );

    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('shows user data after query resolves', async () => {
    render(
      <MockedProvider mocks={[userMock]} addTypename={false}>
        <UserProfile userId="1" />
      </MockedProvider>
    );

    // Wait for the mock to resolve
    await act(async () => {
      await new Promise(resolve => setTimeout(resolve, 0));
    });

    expect(screen.getByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });

  it('shows error state when query fails', async () => {
    const errorMock = {
      request: { query: GET_USER, variables: { id: '1' } },
      error: new Error('User not found'),
    };

    render(
      <MockedProvider mocks={[errorMock]} addTypename={false}>
        <UserProfile userId="1" />
      </MockedProvider>
    );

    await act(async () => {
      await new Promise(resolve => setTimeout(resolve, 0));
    });

    expect(screen.getByText(/Error: User not found/)).toBeInTheDocument();
  });
});

Testing Mutations

// CreateUserForm.jsx
import { useMutation, gql } from '@apollo/client';

export const CREATE_USER = gql`
  mutation CreateUser($name: String!, $email: String!) {
    createUser(name: $name, email: $email) {
      id
      name
    }
  }
`;

export function CreateUserForm({ onCreated }) {
  const [createUser, { loading, error }] = useMutation(CREATE_USER, {
    onCompleted: (data) => onCreated(data.createUser),
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const form = e.target;
    createUser({
      variables: {
        name: form.name.value,
        email: form.email.value,
      },
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" />
      <button type="submit" disabled={loading}>
        {loading ? 'Creating...' : 'Create User'}
      </button>
      {error && <p>Error: {error.message}</p>}
    </form>
  );
}
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { CreateUserForm, CREATE_USER } from './CreateUserForm';

describe('CreateUserForm', () => {
  const mutationMock = {
    request: {
      query: CREATE_USER,
      variables: { name: 'Bob', email: 'bob@example.com' },
    },
    result: {
      data: {
        createUser: { id: '2', name: 'Bob' },
      },
    },
  };

  it('calls onCreated with new user after successful mutation', async () => {
    const onCreated = jest.fn();

    render(
      <MockedProvider mocks={[mutationMock]} addTypename={false}>
        <CreateUserForm onCreated={onCreated} />
      </MockedProvider>
    );

    fireEvent.change(screen.getByPlaceholderText('Name'), {
      target: { value: 'Bob' },
    });
    fireEvent.change(screen.getByPlaceholderText('Email'), {
      target: { value: 'bob@example.com' },
    });
    fireEvent.click(screen.getByText('Create User'));

    await waitFor(() => {
      expect(onCreated).toHaveBeenCalledWith({ id: '2', name: 'Bob' });
    });
  });

  it('shows loading state while mutation is in-flight', async () => {
    render(
      <MockedProvider mocks={[mutationMock]} addTypename={false}>
        <CreateUserForm onCreated={jest.fn()} />
      </MockedProvider>
    );

    fireEvent.change(screen.getByPlaceholderText('Name'), {
      target: { value: 'Bob' },
    });
    fireEvent.change(screen.getByPlaceholderText('Email'), {
      target: { value: 'bob@example.com' },
    });
    fireEvent.click(screen.getByText('Create User'));

    expect(screen.getByText('Creating...')).toBeInTheDocument();
  });
});

Testing Apollo Cache Behavior

When mutations update the cache, test that the UI reflects the cache state:

import { InMemoryCache } from '@apollo/client';

describe('Cache updates', () => {
  it('adds new user to users list after mutation', async () => {
    const cache = new InMemoryCache();

    // Pre-populate cache with existing users
    cache.writeQuery({
      query: GET_USERS,
      data: { users: [{ id: '1', name: 'Alice', __typename: 'User' }] },
    });

    const mocks = [
      {
        request: {
          query: CREATE_USER,
          variables: { name: 'Bob', email: 'bob@example.com' },
        },
        result: {
          data: { createUser: { id: '2', name: 'Bob', __typename: 'User' } },
        },
      },
    ];

    render(
      <MockedProvider mocks={mocks} cache={cache}>
        <UserListWithCreate />
      </MockedProvider>
    );

    // Trigger mutation
    fireEvent.click(screen.getByText('Add Bob'));

    await waitFor(() => {
      expect(screen.getByText('Bob')).toBeInTheDocument();
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });
  });
});

Testing Network Error vs GraphQL Error

describe('Error types', () => {
  it('handles network errors', async () => {
    const mock = {
      request: { query: GET_USER, variables: { id: '1' } },
      error: new Error('Network error'), // Network-level failure
    };

    render(
      <MockedProvider mocks={[mock]} addTypename={false}>
        <UserProfile userId="1" />
      </MockedProvider>
    );

    await act(async () => await new Promise(r => setTimeout(r, 0)));
    expect(screen.getByText(/Error:/)).toBeInTheDocument();
  });

  it('handles GraphQL errors', async () => {
    const mock = {
      request: { query: GET_USER, variables: { id: '1' } },
      result: {
        errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
      },
    };

    render(
      <MockedProvider mocks={[mock]} addTypename={false}>
        <UserProfile userId="1" />
      </MockedProvider>
    );

    await act(async () => await new Promise(r => setTimeout(r, 0)));
    expect(screen.getByText(/User not found/)).toBeInTheDocument();
  });
});

Common Pitfalls

Mock not matching: Apollo matches mocks by deep-comparing the query document and variables. If variables differ by even one field, the mock won't fire and you'll get a "No more mocked responses" error. Log JSON.stringify(variables) in resolver to debug.

addTypename mismatch: If your cache uses addTypename: true (the default), your mock data must include __typename on every object. Use addTypename={false} in tests unless you specifically test cache normalization.

Stale mocks: Each entry in the mocks array is consumed once. If a component re-queries (e.g., polling), add multiple identical mock entries.

Continuous Testing with HelpMeTest

HelpMeTest can verify your Apollo Client interactions end-to-end against a real or staging GraphQL server, catching regressions that unit tests with mocks can miss.

Set up API monitoring for your GraphQL app →

Read more

Start now free