urql GraphQL Client Testing: Mock Exchanges, Offline Support, and Subscriptions
urql is a lightweight, extensible GraphQL client for React that uses an exchange-based architecture. Unlike Apollo's MockedProvider, urql testing uses mock exchanges to intercept operations. This guide covers everything from basic query mocking to offline support testing and subscriptions.
Understanding urql's Exchange Architecture
urql processes GraphQL operations through a pipeline of exchanges — middleware functions that transform operations and results. In tests, you replace the network exchange with a mock that returns controlled responses.
The key import: @urql/testing provides mockExchange for simple cases, or you can build custom mock exchanges for complex scenarios.
npm install --save-dev urql @urql/testing @testing-library/reactBasic Setup with Mock Exchanges
import { createClient, Provider } from 'urql';
import { fromValue, never } from 'wonka';
import { render, screen, waitFor } from '@testing-library/react';
import { gql } from 'urql';
import ProductCard from '../components/ProductCard';
const GET_PRODUCT = gql`
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
description
}
}
`;
function createMockClient(mockResponse) {
return createClient({
url: 'http://test.example.com/graphql',
exchanges: [
({ forward }) => (ops$) => {
return {
source: fromValue({
data: mockResponse,
error: undefined,
extensions: undefined,
operation: null,
stale: false,
hasNext: false,
}),
};
},
],
});
}For more realistic testing, use urql's @urql/testing package:
import { createMockClient } from '@urql/testing';
const mockClient = createMockClient({
executeQuery: ({ query, variables }) => {
if (variables.id === '1') {
return Promise.resolve({
data: {
product: {
id: '1',
name: 'Running Shoes',
price: 129.99,
description: 'Lightweight trail runners',
},
},
});
}
return Promise.resolve({ error: { message: 'Product not found' } });
},
});Testing Queries
function renderWithClient(component, client) {
return render(<Provider value={client}>{component}</Provider>);
}
test('displays product data on successful query', async () => {
const client = createMockClient({
executeQuery: () => Promise.resolve({
data: {
product: { id: '1', name: 'Trail Runners', price: 149, description: 'Fast shoes' },
},
}),
});
renderWithClient(<ProductCard productId="1" />, client);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Trail Runners')).toBeInTheDocument();
});
expect(screen.getByText('$149.00')).toBeInTheDocument();
});
test('displays error when query fails', async () => {
const client = createMockClient({
executeQuery: () => Promise.resolve({
error: { message: 'Product not found', graphQLErrors: [{ message: 'Product not found' }] },
}),
});
renderWithClient(<ProductCard productId="nonexistent" />, client);
await waitFor(() => {
expect(screen.getByText('Product not found')).toBeInTheDocument();
});
});Building a Custom Mock Exchange
For complex test scenarios, build a custom exchange that matches operations to responses:
// test-utils/urql-mock-exchange.js
import { fromValue, makeSubject, pipe, map, filter, mergeMap } from 'wonka';
import { makeErrorResult, makeResult } from '@urql/core';
export function createMockExchange(mocks = []) {
return ({ forward }) => (ops$) => {
return pipe(
ops$,
mergeMap((operation) => {
const mock = mocks.find(m => {
// Match by operation name
const opName = operation.query.definitions[0]?.name?.value;
return m.operationName === opName &&
(!m.variables || JSON.stringify(m.variables) === JSON.stringify(operation.variables));
});
if (!mock) {
// Fall through to real exchange if no mock found
return forward([operation]);
}
if (mock.error) {
return fromValue(makeErrorResult(operation, new Error(mock.error)));
}
return fromValue(makeResult(operation, { data: mock.data }));
})
);
};
}
// Usage:
const client = createClient({
url: '/graphql',
exchanges: [
createMockExchange([
{
operationName: 'GetProduct',
variables: { id: '1' },
data: { product: { id: '1', name: 'Shoes', price: 99, description: 'Fast' } },
},
]),
],
});Testing Mutations
const ADD_TO_WISHLIST = gql`
mutation AddToWishlist($productId: ID!) {
addToWishlist(productId: $productId) {
success
wishlistCount
}
}
`;
test('adds product to wishlist on button click', async () => {
const user = userEvent.setup();
const executeMutation = jest.fn().mockResolvedValue({
data: { addToWishlist: { success: true, wishlistCount: 3 } },
});
const client = createMockClient({ executeMutation });
renderWithClient(<ProductCard productId="1" />, client);
await waitFor(() => screen.getByText('Trail Runners'));
await user.click(screen.getByRole('button', { name: 'Add to Wishlist' }));
expect(executeMutation).toHaveBeenCalledWith(
expect.objectContaining({ productId: '1' })
);
await waitFor(() => {
expect(screen.getByText('Wishlist (3)')).toBeInTheDocument();
});
});Testing with Cache Exchanges (document cache)
urql's default cacheExchange caches query results. Test that your cache configuration works:
import { cacheExchange, createClient, Provider } from 'urql';
test('serves data from cache on second render', async () => {
const executeQuery = jest.fn().mockResolvedValue({
data: {
product: { id: '1', name: 'Cached Product', price: 99, description: 'Cached' },
},
});
// Create client with real cache exchange
const client = createClient({
url: '/graphql',
exchanges: [
cacheExchange,
// Mock fetch exchange
() => (ops$) => pipe(ops$, mergeMap(() => fromValue({
data: executeQuery(),
error: undefined,
stale: false,
hasNext: false,
}))),
],
});
const { rerender } = renderWithClient(<ProductCard productId="1" />, client);
await waitFor(() => screen.getByText('Cached Product'));
// Re-render same component
rerender(<Provider value={client}><ProductCard productId="1" /></Provider>);
// Should serve from cache — executeQuery called only once
expect(executeQuery).toHaveBeenCalledTimes(1);
expect(screen.getByText('Cached Product')).toBeInTheDocument();
});Testing Offline Support
urql works well with offline patterns via requestPolicy. Test that cache-only works when offline:
test('serves stale data from cache when network is unavailable', async () => {
const networkError = new Error('Network unavailable');
let shouldFail = false;
const client = createClient({
url: '/graphql',
exchanges: [
cacheExchange,
() => (ops$) => pipe(ops$, mergeMap(() => {
if (shouldFail) {
return fromValue({
error: { networkError },
data: undefined,
stale: true,
hasNext: false,
});
}
return fromValue({
data: { product: { id: '1', name: 'Online Product', price: 99, description: 'Live' } },
error: undefined,
stale: false,
hasNext: false,
});
})),
],
});
// First load — network available
renderWithClient(<ProductCard productId="1" />, client);
await waitFor(() => screen.getByText('Online Product'));
// Go "offline"
shouldFail = true;
// Refetch — should get stale data from cache, not error
// Note: actual offline behavior depends on your requestPolicy setup
});
test('uses cache-only policy for offline mode', async () => {
const client = createMockClient({
executeQuery: () => Promise.resolve({
data: { product: { id: '1', name: 'Cached', price: 99, description: 'Cached' } },
}),
});
// Pre-populate cache, then render with cache-only
renderWithClient(
<ProductCard productId="1" requestPolicy="cache-only" />,
client
);
// With cache-only, component renders immediately from cache or shows empty state
// Actual behavior depends on your component implementation
});Testing Subscriptions
urql subscriptions require a subscription exchange (typically with WebSockets). In tests, mock the subscription exchange:
import { subscriptionExchange, createClient } from 'urql';
import { Subject } from 'rxjs';
import { from } from 'wonka';
const NOTIFICATION_SUBSCRIPTION = gql`
subscription OnNotification($userId: ID!) {
notification(userId: $userId) {
id
message
type
}
}
`;
test('displays notifications from subscription', async () => {
const notificationSubject = new Subject();
const client = createClient({
url: '/graphql',
exchanges: [
subscriptionExchange({
forwardSubscription: (operation) => ({
subscribe: (observer) => {
const subscription = notificationSubject.subscribe(observer);
return { unsubscribe: () => subscription.unsubscribe() };
},
}),
}),
],
});
renderWithClient(<NotificationBell userId="user-1" />, client);
expect(screen.getByText('No notifications')).toBeInTheDocument();
// Push a notification through the subject
act(() => {
notificationSubject.next({
data: {
notification: { id: '1', message: 'Your order shipped!', type: 'INFO' },
},
});
});
await waitFor(() => {
expect(screen.getByText('Your order shipped!')).toBeInTheDocument();
});
// Push another
act(() => {
notificationSubject.next({
data: {
notification: { id: '2', message: 'Payment received', type: 'SUCCESS' },
},
});
});
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(2);
});
});Testing Network Retries
If you use urql's retryExchange, test that retries work correctly:
import { retryExchange } from '@urql/exchange-retry';
test('retries failed query up to max attempts', async () => {
let attemptCount = 0;
const maxAttempts = 3;
const client = createClient({
url: '/graphql',
exchanges: [
retryExchange({ maxNumberAttempts: maxAttempts }),
() => (ops$) => pipe(ops$, mergeMap(() => {
attemptCount++;
return fromValue({
error: { networkError: new Error('Network failed') },
data: undefined,
stale: false,
hasNext: false,
});
})),
],
});
renderWithClient(<ProductCard productId="1" />, client);
await waitFor(() => {
expect(attemptCount).toBe(maxAttempts);
});
expect(screen.getByText('Network error. Please try again.')).toBeInTheDocument();
});E2E Testing with HelpMeTest
urql mock exchange tests verify your client logic, but production GraphQL behavior — WebSocket connections for subscriptions, cache invalidation, and network error handling — needs E2E coverage:
Go to https://your-app.com/notifications
Verify notification bell shows 0 count
Trigger a server-side event (via API or UI action)
Verify notification count increments without page refresh
Click the notification bell
Verify notification list displays the new message
Mark notification as read
Verify notification count decrementsHelpMeTest monitors subscription reliability, catch reconnection issues, and verifies urql's offline cache behavior continuously.
Summary
Testing urql effectively requires:
- Custom mock exchanges — replace the fetch exchange to control operation responses
- Query tests — loading, data, and error state verification
- Mutation tests — trigger, variable verification, response handling
- Cache exchange tests — verify caching behavior and
requestPolicyoptions - Subscription tests — push events through a test subject to simulate server pushes
- Retry exchange tests — verify maxAttempts and backoff behavior
- E2E monitoring — HelpMeTest for WebSocket subscription health and production GraphQL reliability