GraphQL Client Mocking Strategies: MSW, graphql-tools Mock Schema, and Beyond

GraphQL Client Mocking Strategies: MSW, graphql-tools Mock Schema, and Beyond

When testing GraphQL client code, you have multiple layers where you can inject mocks: at the network level (MSW), at the schema level (graphql-tools), at the client level (Apollo MockedProvider, urql exchanges), or at the component level. Each approach has tradeoffs. This guide compares them and shows when to use each.

The Four Layers of GraphQL Mocking

Component
    ↓
GraphQL Client (Apollo, urql, Relay)
    ↓
HTTP Transport (fetch, WebSocket)
    ↓
Server

You can intercept at any layer:

Layer Tool Tests Speed Realism
Component jest.mock() Isolated component logic Fastest Low
Client MockedProvider, urql exchanges, MockEnvironment Client-component integration Fast Medium
Network MSW Full client behavior Medium High
Schema graphql-tools Schema contract Medium High

The right choice depends on what you're testing.

MSW for Network-Level Mocking

Mock Service Worker intercepts actual fetch calls. Your GraphQL client behaves exactly as it would in production — the only difference is what's on the other end of the network call.

Setup

npm install --save-dev msw
npx msw init public/ --save
// test-setup/msw-handlers.js
import { graphql, HttpResponse } from 'msw';

export const handlers = [
  graphql.query('GetUser', ({ variables }) => {
    if (variables.id === '1') {
      return HttpResponse.json({
        data: {
          user: {
            id: '1',
            name: 'Jane Smith',
            email: 'jane@example.com',
            role: 'ADMIN',
          },
        },
      });
    }
    return HttpResponse.json({
      errors: [{ message: 'User not found' }],
    });
  }),

  graphql.mutation('UpdateUser', ({ variables }) => {
    return HttpResponse.json({
      data: {
        updateUser: {
          id: variables.id,
          name: variables.input.name,
          email: variables.input.email,
        },
      },
    });
  }),

  graphql.subscription('OrderStatus', () => {
    // Subscriptions need a different approach (see below)
  }),
];
// test-setup/msw-server.js
import { setupServer } from 'msw/node';
import { handlers } from './msw-handlers';

export const server = setupServer(...handlers);

// In jest setup file:
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Using MSW in Tests

import { server } from '../test-setup/msw-server';
import { graphql, HttpResponse } from 'msw';

test('displays user profile', async () => {
  // Uses default handler — works for all clients (Apollo, urql, Relay, fetch)
  render(<UserProfile userId="1" />);

  await waitFor(() => {
    expect(screen.getByText('Jane Smith')).toBeInTheDocument();
  });
});

test('shows error for unknown user', async () => {
  // Override handler for this specific test
  server.use(
    graphql.query('GetUser', () => {
      return HttpResponse.json({
        errors: [{ message: 'Unauthorized' }],
      });
    })
  );

  render(<UserProfile userId="1" />);

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

MSW for Delayed Responses

Test loading states and timeouts:

import { delay } from 'msw';

test('shows loading skeleton during slow response', async () => {
  server.use(
    graphql.query('GetUser', async () => {
      await delay(1000); // 1 second delay
      return HttpResponse.json({
        data: { user: { id: '1', name: 'Jane', email: 'j@e.com', role: 'USER' } },
      });
    })
  );

  render(<UserProfile userId="1" />);

  expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText('Jane')).toBeInTheDocument();
  }, { timeout: 2000 });
});

graphql-tools Schema Mocking

graphql-tools lets you create a fully mock GraphQL server from your schema definition. This is ideal for testing against the real schema without a backend:

npm install --save-dev @graphql-tools/mock @graphql-tools/schema graphql

Basic Schema Mock Server

import { addMocksToSchema } from '@graphql-tools/mock';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { graphql } from 'graphql';

const typeDefs = `
  type User {
    id: ID!
    name: String!
    email: String!
    role: UserRole!
    posts: [Post!]!
  }
  
  enum UserRole { ADMIN USER GUEST }
  
  type Post {
    id: ID!
    title: String!
    publishedAt: String
  }
  
  type Query {
    user(id: ID!): User
    users(limit: Int): [User!]!
  }
  
  type Mutation {
    updateUser(id: ID!, name: String!): User
  }
`;

const schema = makeExecutableSchema({ typeDefs });

// Add default mocks — auto-generates values for all types
const mockedSchema = addMocksToSchema({ schema });

Customizing Mock Resolvers

Override specific types and fields:

const customMocks = {
  User: () => ({
    id: () => 'user-1',
    name: () => 'Generated User',
    email: () => 'generated@example.com',
    role: () => 'ADMIN',
  }),
  Post: () => ({
    id: () => `post-${Math.random().toString(36).slice(2)}`,
    title: () => 'Generated Post Title',
    publishedAt: () => new Date().toISOString(),
  }),
  // Scalars
  ID: () => `id-${Math.random().toString(36).slice(2)}`,
  String: () => 'Mock String',
};

const mockedSchema = addMocksToSchema({ schema, mocks: customMocks });

// Use in test
const result = await graphql({
  schema: mockedSchema,
  source: `query { user(id: "1") { id name email role } }`,
});

console.log(result.data.user); 
// { id: 'user-1', name: 'Generated User', email: 'generated@example.com', role: 'ADMIN' }

Serving Mock Schema with MSW

Combine graphql-tools with MSW for a powerful pattern — your handlers execute against the mock schema, ensuring type correctness:

import { graphql as graphqlTools } from 'graphql';
import { graphql as mswGraphql, HttpResponse } from 'msw';

function createSchemaHandler(operationName, schema) {
  return mswGraphql.all(async ({ query, variables }) => {
    const result = await graphqlTools({
      schema,
      source: query,
      variableValues: variables,
    });
    return HttpResponse.json(result);
  });
}

const server = setupServer(createSchemaHandler('*', mockedSchema));

Comparing the Approaches

When to Use MSW

  • Testing a component with any GraphQL client without client-specific setup
  • Integration tests where you want realistic HTTP behavior
  • Testing error responses, timeouts, and network failures
  • Shared mock data across multiple tests or test files
  • Testing the Apollo/urql/Relay error handling, retries, and caching at the HTTP level
// Perfect MSW use case: cross-client integration test
test('Apollo and urql both handle the same response correctly', async () => {
  // MSW handler applies to both clients
  const apolloResult = render(
    <ApolloProvider client={apolloClient}>
      <UserProfile userId="1" />
    </ApolloProvider>
  );
  await waitFor(() => screen.getByText('Jane Smith'));
  apolloResult.unmount();

  const urqlResult = render(
    <Provider value={urqlClient}>
      <UserProfile userId="1" />
    </Provider>
  );
  await waitFor(() => screen.getByText('Jane Smith'));
});

When to Use Client-Level Mocks (MockedProvider, mock exchanges)

  • Unit tests for component-specific behavior (loading states, error display, optimistic UI)
  • Testing Apollo cache interactions
  • Testing Relay fragment composition
  • Tests that need to simulate client-specific features (Apollo reactive variables, Relay store updates)
// Perfect Apollo MockedProvider use case: testing optimistic UI
test('shows optimistic like count before server confirms', async () => {
  let resolve;
  const mocks = [{
    request: { query: LIKE_POST, variables: { postId: '1' } },
    result: () => new Promise(r => { resolve = r; }),
  }];

  // Only possible with client-level mocking — MSW can't pause response mid-test easily
  render(<MockedProvider mocks={mocks}><LikeButton postId="1" /></MockedProvider>);
  await user.click(screen.getByRole('button'));
  
  // Assert optimistic state before server responds
  expect(screen.getByText('43 likes')).toBeInTheDocument();
  
  resolve({ data: { likePost: { likeCount: 43 } } });
});

When to Use graphql-tools Schema Mocking

  • Testing query compatibility against your actual schema
  • Catching schema breaking changes in CI
  • Generating large, realistic datasets for complex components
  • Contract testing between frontend and backend
// graphql-tools: perfect for schema contract testing
test('all queries in the codebase are compatible with the schema', async () => {
  const queries = [GET_USER, GET_PRODUCTS, GET_ORDER]; // all your app's queries
  
  for (const query of queries) {
    const result = await graphql({ schema: mockedSchema, source: print(query) });
    expect(result.errors).toBeUndefined();
  }
});

Practical Patterns

Shared Mock Factory

Create a central mock factory that all tests can use:

// test-utils/graphql-mocks.js
export const mockUser = (overrides = {}) => ({
  id: 'user-1',
  name: 'Test User',
  email: 'test@example.com',
  role: 'USER',
  ...overrides,
});

export const mockProduct = (overrides = {}) => ({
  id: 'product-1',
  name: 'Test Product',
  price: 99.99,
  inStock: true,
  ...overrides,
});

// MSW handler using factory
export const defaultHandlers = [
  graphql.query('GetUser', ({ variables }) =>
    HttpResponse.json({ data: { user: mockUser({ id: variables.id }) } })
  ),
  graphql.query('GetProduct', ({ variables }) =>
    HttpResponse.json({ data: { product: mockProduct({ id: variables.id }) } })
  ),
];

Testing Partial Responses

GraphQL allows partial success — some fields succeed while others return errors:

test('renders partial data with inline errors', async () => {
  server.use(
    graphql.query('GetDashboard', () =>
      HttpResponse.json({
        data: {
          user: { id: '1', name: 'Jane' },
          analytics: null, // This field failed
        },
        errors: [
          {
            message: 'Analytics service unavailable',
            path: ['analytics'],
          },
        ],
      })
    )
  );

  render(<Dashboard userId="1" />);

  await waitFor(() => {
    expect(screen.getByText('Jane')).toBeInTheDocument();
    expect(screen.getByText('Analytics unavailable')).toBeInTheDocument();
  });
});

E2E Validation with HelpMeTest

Mock-based tests are fast, but they can't catch server-side schema changes, production API bugs, or client-server version mismatches. HelpMeTest's E2E tests validate your GraphQL integration in real browsers against real servers:

Navigate to https://your-app.com/dashboard
Verify user name appears in the header
Verify analytics panel loads within 3 seconds
Verify product list loads and shows at least 5 items
Filter products by category
Verify filtered results are correct
Update profile name via the edit form
Verify name change persists after page refresh

These tests run continuously, alerting you when your GraphQL server changes break the client.

Summary

Choose your GraphQL mocking strategy based on what you're testing:

  1. MSW — best for cross-client integration tests, realistic HTTP behavior, and shared mocks
  2. graphql-tools schema mocking — best for schema contract testing and type-correct data generation
  3. Client mocks (MockedProvider, mock exchanges, MockEnvironment) — best for component unit tests and client-specific features
  4. Combination — use MSW for most tests, client-level mocks when you need to control client internals

In all cases, complement your mock tests with E2E monitoring via HelpMeTest to catch production GraphQL regressions that mocks can never detect.

Read more