Apollo Client + React Testing Library: Hook Testing and Optimistic UI

Apollo Client + React Testing Library: Hook Testing and Optimistic UI

Apollo Client's hook API (useQuery, useMutation, useLazyQuery, useSubscription) is how most React apps interact with GraphQL today. Testing these hooks properly — both in isolation and within components — requires understanding React Testing Library's async patterns and Apollo's MockedProvider. This guide covers the full hook testing lifecycle.

Testing useQuery Hooks

The useQuery hook fires on mount and returns { data, loading, error }. Your tests need to verify all three states:

import { renderHook, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { gql, useQuery } from '@apollo/client';

const GET_PRODUCTS = gql`
  query GetProducts($category: String!) {
    products(category: $category) {
      id
      name
      price
      inStock
    }
  }
`;

function useProducts(category) {
  return useQuery(GET_PRODUCTS, { variables: { category } });
}

const wrapper = ({ children }) => (
  <MockedProvider
    mocks={[{
      request: { query: GET_PRODUCTS, variables: { category: 'electronics' } },
      result: {
        data: {
          products: [
            { id: '1', name: 'Laptop', price: 999, inStock: true },
            { id: '2', name: 'Phone', price: 699, inStock: false },
          ],
        },
      },
    }]}
    addTypename={false}
  >
    {children}
  </MockedProvider>
);

test('useProducts returns loading then data', async () => {
  const { result } = renderHook(() => useProducts('electronics'), { wrapper });

  // Initial state: loading
  expect(result.current.loading).toBe(true);
  expect(result.current.data).toBeUndefined();

  // After response
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.data.products).toHaveLength(2);
  expect(result.current.data.products[0].name).toBe('Laptop');
});

test('useProducts returns error state on failure', async () => {
  const errorWrapper = ({ children }) => (
    <MockedProvider
      mocks={[{
        request: { query: GET_PRODUCTS, variables: { category: 'invalid' } },
        result: { errors: [{ message: 'Invalid category' }] },
      }]}
      addTypename={false}
    >
      {children}
    </MockedProvider>
  );

  const { result } = renderHook(() => useProducts('invalid'), { wrapper: errorWrapper });

  await waitFor(() => expect(result.current.loading).toBe(false));

  expect(result.current.error).toBeDefined();
  expect(result.current.error.message).toContain('Invalid category');
  expect(result.current.data).toBeUndefined();
});

Testing useLazyQuery

useLazyQuery doesn't fire immediately — it returns a trigger function:

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

const SEARCH_PRODUCTS = gql`
  query SearchProducts($query: String!) {
    search(query: $query) {
      id
      name
      relevanceScore
    }
  }
`;

function useProductSearch() {
  const [search, { data, loading, called }] = useLazyQuery(SEARCH_PRODUCTS);
  return { search, results: data?.search || [], loading, hasSearched: called };
}

test('useLazyQuery does not fire on mount', () => {
  const wrapper = ({ children }) => (
    <MockedProvider mocks={[]} addTypename={false}>{children}</MockedProvider>
  );

  const { result } = renderHook(() => useProductSearch(), { wrapper });

  expect(result.current.hasSearched).toBe(false);
  expect(result.current.loading).toBe(false);
  expect(result.current.results).toHaveLength(0);
});

test('useLazyQuery fires when trigger is called', async () => {
  const wrapper = ({ children }) => (
    <MockedProvider
      mocks={[{
        request: { query: SEARCH_PRODUCTS, variables: { query: 'laptop' } },
        result: {
          data: {
            search: [{ id: '1', name: 'Gaming Laptop', relevanceScore: 0.95 }],
          },
        },
      }]}
      addTypename={false}
    >
      {children}
    </MockedProvider>
  );

  const { result } = renderHook(() => useProductSearch(), { wrapper });

  // Trigger the search
  act(() => {
    result.current.search({ variables: { query: 'laptop' } });
  });

  expect(result.current.loading).toBe(true);
  expect(result.current.hasSearched).toBe(true);

  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.results).toHaveLength(1);
  expect(result.current.results[0].name).toBe('Gaming Laptop');
});

Testing useMutation Hooks

Test mutations by verifying the mutation fires with correct variables and the returned data is processed correctly:

import { act } from '@testing-library/react';
import { useMutation } from '@apollo/client';

const ADD_TO_CART = gql`
  mutation AddToCart($productId: ID!, $quantity: Int!) {
    addToCart(productId: $productId, quantity: $quantity) {
      cartId
      itemCount
      total
    }
  }
`;

function useCart() {
  const [addToCart, { data, loading, error }] = useMutation(ADD_TO_CART);
  return {
    addToCart: (productId, quantity) =>
      addToCart({ variables: { productId, quantity } }),
    cartData: data?.addToCart,
    adding: loading,
    addError: error,
  };
}

test('useMutation executes and returns updated cart', async () => {
  const wrapper = ({ children }) => (
    <MockedProvider
      mocks={[{
        request: {
          query: ADD_TO_CART,
          variables: { productId: 'prod-1', quantity: 2 },
        },
        result: {
          data: {
            addToCart: { cartId: 'cart-123', itemCount: 3, total: 250.00 },
          },
        },
      }]}
      addTypename={false}
    >
      {children}
    </MockedProvider>
  );

  const { result } = renderHook(() => useCart(), { wrapper });

  expect(result.current.adding).toBe(false);

  await act(async () => {
    await result.current.addToCart('prod-1', 2);
  });

  expect(result.current.cartData).toEqual({
    cartId: 'cart-123',
    itemCount: 3,
    total: 250.00,
  });
  expect(result.current.adding).toBe(false);
  expect(result.current.addError).toBeUndefined();
});

Testing Optimistic UI in Components

Optimistic UI tests need careful timing — assert the optimistic state before the mock resolves:

import userEvent from '@testing-library/user-event';

function LikeButton({ postId, initialLikes }) {
  const LIKE_POST = gql`
    mutation LikePost($postId: ID!) {
      likePost(postId: $postId) { id likeCount }
    }
  `;

  const [likePost, { loading }] = useMutation(LIKE_POST, {
    optimisticResponse: {
      likePost: { __typename: 'Post', id: postId, likeCount: initialLikes + 1 },
    },
    update(cache, { data: { likePost } }) {
      cache.modify({
        id: cache.identify({ __typename: 'Post', id: postId }),
        fields: { likeCount: () => likePost.likeCount },
      });
    },
  });

  return (
    <button onClick={() => likePost({ variables: { postId } })} disabled={loading}>
      {initialLikes} Likes
    </button>
  );
}

test('optimistic update shows incremented like count immediately', async () => {
  const user = userEvent.setup();
  let resolveServerResponse;

  const wrapper = ({ children }) => (
    <MockedProvider
      mocks={[{
        request: {
          query: gql`mutation LikePost($postId: ID!) { likePost(postId: $postId) { id likeCount } }`,
          variables: { postId: 'post-1' },
        },
        result: () => new Promise(resolve => {
          resolveServerResponse = () => resolve({
            data: { likePost: { id: 'post-1', likeCount: 42 } },
          });
        }),
      }]}
      // Note: addTypename MUST be true for optimistic responses
    >
      {children}
    </MockedProvider>
  );

  render(
    <wrapper>
      <LikeButton postId="post-1" initialLikes={41} />
    </wrapper>
  );

  expect(screen.getByRole('button')).toHaveTextContent('41 Likes');

  await user.click(screen.getByRole('button'));

  // Optimistic: count shows 42 immediately
  expect(screen.getByRole('button')).toHaveTextContent('42 Likes');

  // Resolve server response
  act(() => resolveServerResponse());

  await waitFor(() => {
    expect(screen.getByRole('button')).toHaveTextContent('42 Likes');
  });
});

Testing useSubscription

Subscriptions are harder to test because they represent ongoing streams. Apollo's test utilities handle this:

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

const ORDER_STATUS_SUBSCRIPTION = gql`
  subscription OrderStatus($orderId: ID!) {
    orderStatusChanged(orderId: $orderId) {
      orderId
      status
      updatedAt
    }
  }
`;

function useOrderTracking(orderId) {
  const { data, loading } = useSubscription(ORDER_STATUS_SUBSCRIPTION, {
    variables: { orderId },
  });
  return {
    status: data?.orderStatusChanged?.status || 'PENDING',
    loading,
  };
}

test('subscription updates status when server pushes update', async () => {
  const MOCK_SUBSCRIPTION_DATA = {
    orderStatusChanged: { orderId: 'order-1', status: 'SHIPPED', updatedAt: '2026-01-15T10:00:00Z' },
  };

  const wrapper = ({ children }) => (
    <MockedProvider
      mocks={[{
        request: {
          query: ORDER_STATUS_SUBSCRIPTION,
          variables: { orderId: 'order-1' },
        },
        result: { data: MOCK_SUBSCRIPTION_DATA },
      }]}
      addTypename={false}
    >
      {children}
    </MockedProvider>
  );

  const { result } = renderHook(() => useOrderTracking('order-1'), { wrapper });

  expect(result.current.status).toBe('PENDING');

  await waitFor(() => {
    expect(result.current.status).toBe('SHIPPED');
  });
});

Testing Refetch Behavior

Many UIs use refetch to refresh data after user actions:

test('refetch reloads data with new variables', async () => {
  const user = userEvent.setup();

  const mocks = [
    {
      request: { query: GET_PRODUCTS, variables: { category: 'electronics' } },
      result: { data: { products: [{ id: '1', name: 'Laptop', price: 999, inStock: true }] } },
    },
    {
      request: { query: GET_PRODUCTS, variables: { category: 'clothing' } },
      result: { data: { products: [{ id: '2', name: 'T-Shirt', price: 29, inStock: true }] } },
    },
  ];

  render(
    <MockedProvider mocks={mocks} addTypename={false}>
      <ProductCatalog initialCategory="electronics" />
    </MockedProvider>
  );

  await waitFor(() => screen.getByText('Laptop'));

  // Change category — triggers refetch
  await user.click(screen.getByRole('button', { name: 'Clothing' }));

  await waitFor(() => {
    expect(screen.getByText('T-Shirt')).toBeInTheDocument();
    expect(screen.queryByText('Laptop')).not.toBeInTheDocument();
  });
});

Integration with Apollo DevTools

For debugging failing tests, Apollo Client 3+ supports a connectToDevTools: false option that reduces console noise in test environments:

// test-setup.js
import { ApolloClient, InMemoryCache } from '@apollo/client';

// Suppress devtools connection attempts in test
global.ApolloClient = class extends ApolloClient {
  constructor(options) {
    super({ ...options, connectToDevTools: false });
  }
};

E2E Testing with HelpMeTest

Hook-level tests prove your data fetching logic is correct, but real-world Apollo behavior in browsers — network timing, WebSocket subscriptions, cache serialization — needs E2E coverage:

Navigate to https://your-app.com/orders/order-1
Verify order status displays as "Processing"
Wait 5 seconds (subscription fires)
Verify order status updates to "Shipped" without page refresh
Click "Add to Cart" for a product
Verify cart count updates optimistically in the header
Verify confirmation toast appears after server confirms

HelpMeTest runs these tests continuously, catching subscription reliability issues, optimistic UI rollback bugs, and GraphQL schema changes that break your client.

Summary

Comprehensive Apollo Client + React Testing Library testing covers:

  1. useQuery: loading, data, and error states with MockedProvider
  2. useLazyQuery: verifying it doesn't fire on mount, and fires correctly when triggered
  3. useMutation: correct variable passing and response processing
  4. Optimistic UI: asserting optimistic state before server response arrives
  5. useSubscription: stream updates with controlled mock timing
  6. Refetch: variable changes triggering new requests with fresh data
  7. E2E monitoring: HelpMeTest for real browser behavior verification

Read more