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 confirmsHelpMeTest 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:
- useQuery: loading, data, and error states with
MockedProvider - useLazyQuery: verifying it doesn't fire on mount, and fires correctly when triggered
- useMutation: correct variable passing and response processing
- Optimistic UI: asserting optimistic state before server response arrives
- useSubscription: stream updates with controlled mock timing
- Refetch: variable changes triggering new requests with fresh data
- E2E monitoring: HelpMeTest for real browser behavior verification