urql GraphQL Client Testing: Mock Exchanges, Offline Support, and Subscriptions

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/react

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

HelpMeTest monitors subscription reliability, catch reconnection issues, and verifies urql's offline cache behavior continuously.

Summary

Testing urql effectively requires:

  1. Custom mock exchanges — replace the fetch exchange to control operation responses
  2. Query tests — loading, data, and error state verification
  3. Mutation tests — trigger, variable verification, response handling
  4. Cache exchange tests — verify caching behavior and requestPolicy options
  5. Subscription tests — push events through a test subject to simulate server pushes
  6. Retry exchange tests — verify maxAttempts and backoff behavior
  7. E2E monitoring — HelpMeTest for WebSocket subscription health and production GraphQL reliability

Read more