Mocking GraphQL Queries and Mutations with MSW

You need your frontend to render against real-looking GraphQL responses — a fetched profile, a successful create, a validation error — but the backend resolver is not written yet, or you want deterministic values a test can assert on. This page shows exactly how to write graphql.query and graphql.mutation handlers that read variables, return typed data, and reproduce GraphQL’s errors[] semantics.

Context: why this trips people up

Developers arriving from REST mocking expect to key on a URL and to signal failure with a status code. GraphQL breaks both habits. Every operation is a POST to one endpoint, so MSW matches on the operation name declared in the document, and a failure is reported as an entry in an errors array with an HTTP 200, because the HTTP request itself succeeded. Getting the envelope wrong is the difference between an error boundary that renders and a client that treats a failure as success.

This guide assumes you have already registered the worker and server from the GraphQL mocking setup — it drills into the handler bodies themselves.

Solution

1. Type each operation

Give every operation a data interface and a variables interface, then pass them as the two generics to graphql.query or graphql.mutation. The resolver’s variables argument becomes fully typed, and TypeScript checks the object you return.

// src/mocks/catalog-handlers.ts
import { graphql, HttpResponse } from 'msw'

type Currency = 'USD' | 'EUR' | 'GBP'

interface Product {
  __typename: 'Product'
  id: string
  title: string
  priceCents: number
  currency: Currency
  inStock: boolean
}

interface GetProductData {
  product: Product | null
}

interface GetProductVariables {
  id: string
}

const catalog: Record<string, Product> = {
  'p_1': {
    __typename: 'Product',
    id: 'p_1',
    title: 'Mechanical Keyboard',
    priceCents: 12900,
    currency: 'USD',
    inStock: true,
  },
  'p_2': {
    __typename: 'Product',
    id: 'p_2',
    title: 'Desk Mat',
    priceCents: 2900,
    currency: 'USD',
    inStock: false,
  },
}

export const getProductHandler = graphql.query<GetProductData, GetProductVariables>(
  'GetProduct',
  ({ variables }) => {
    const product = catalog[variables.id] ?? null
    return HttpResponse.json({ data: { product } })
  },
)

Note the __typename field: normalising clients such as Apollo Client use it to key their cache. Omitting it from a hand-written resolver causes refetch loops, so include it on every object.

2. Read variables inside a mutation

Mutations receive their input the same way — through the typed variables object. The resolver below mutates the in-memory catalog and returns the updated node so the client cache stays consistent.

// src/mocks/catalog-handlers.ts (continued)
interface UpdateStockVariables {
  input: { productId: string; inStock: boolean }
}

interface UpdateStockData {
  updateStock: Product
}

export const updateStockHandler = graphql.mutation<UpdateStockData, UpdateStockVariables>(
  'UpdateStock',
  ({ variables }) => {
    const { productId, inStock } = variables.input
    const existing = catalog[productId]

    if (!existing) {
      return HttpResponse.json({
        errors: [
          {
            message: `Product ${productId} not found`,
            path: ['updateStock'],
            extensions: { code: 'NOT_FOUND' },
          },
        ],
      })
    }

    existing.inStock = inStock
    return HttpResponse.json({ data: { updateStock: existing } })
  },
)

3. Simulate GraphQL errors

A GraphQL error is a structured object, not a bare string. The three fields clients rely on are message (human-readable), path (which field in the selection set failed), and extensions.code (a machine-readable category such as UNAUTHENTICATED or BAD_USER_INPUT). Return them at HTTP 200.

// src/mocks/auth-handlers.ts
import { graphql, HttpResponse } from 'msw'

interface ViewerData {
  viewer: { __typename: 'User'; id: string; displayName: string } | null
}

export const viewerHandler = graphql.query<ViewerData>('Viewer', ({ request }) => {
  const token = request.headers.get('Authorization')

  if (!token) {
    // A field-level error at HTTP 200: transport succeeded, resolution did not.
    return HttpResponse.json({
      data: { viewer: null },
      errors: [
        {
          message: 'You must be signed in to read the viewer',
          path: ['viewer'],
          extensions: { code: 'UNAUTHENTICATED' },
        },
      ],
    })
  }

  return HttpResponse.json({
    data: { viewer: { __typename: 'User', id: 'u_1', displayName: 'Ada Lovelace' } },
  })
})

This example also demonstrates partial data plus an errordata.viewer is null while errors explains why. GraphQL explicitly permits a response to carry both, and a well-built client renders the successful parts of the tree while routing the failed path to an error boundary. Structuring these mixed responses is closely related to the response shaping techniques used for REST payloads.

4. Register the handlers

Add the exported handlers to your combined list. They sit before the schema auto-mock so they take precedence, as described in the parent GraphQL mocking setup.

// src/mocks/handlers.ts
import { getProductHandler, updateStockHandler } from './catalog-handlers'
import { viewerHandler } from './auth-handlers'

export const handlers = [
  getProductHandler,
  updateStockHandler,
  viewerHandler,
]

For a single test that needs a different outcome — say, a server error on GetProduct — override it with server.use(), which prepends the handler so it wins for that test only:

// product.test.ts
import { graphql, HttpResponse } from 'msw'
import { server } from '../mocks/node'

test('renders an error state when the product query fails', async () => {
  server.use(
    graphql.query('GetProduct', () =>
      HttpResponse.json({
        errors: [{ message: 'Upstream unavailable', extensions: { code: 'INTERNAL_SERVER_ERROR' } }],
      }),
    ),
  )
  // then render the component and assert the error UI appears
})

Verification

POST each operation to the mocked endpoint and confirm the envelope. Run this against your dev server (the default Vite port is used here):

curl -s http://localhost:5173/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"query GetProduct($id: ID!){ product(id:$id){ id title priceCents inStock } }","variables":{"id":"p_1"},"operationName":"GetProduct"}'

Expected output:

{"data":{"product":{"id":"p_1","title":"Mechanical Keyboard","priceCents":12900,"inStock":true}}}

Confirm the unauthenticated path returns partial data plus an error at status 200:

curl -s -w "\n%{http_code}\n" http://localhost:5173/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"query Viewer { viewer { id displayName } }","operationName":"Viewer"}'

Expected output — note the trailing 200, not a 4xx:

{"data":{"viewer":null},"errors":[{"message":"You must be signed in to read the viewer","path":["viewer"],"extensions":{"code":"UNAUTHENTICATED"}}]}
200

Gotchas and edge cases

  • Anonymous operations never match. graphql.query('GetProduct') requires the client to send a document declaring query GetProduct { product { id } }. If your client fires an unnamed query { product { id } }, the handler is skipped and the request escapes. Name every operation, and keep onUnhandledRequest: 'error' on so the miss fails the test rather than reaching the network.

  • Returning a status instead of an errors[] entry misleads the client. Calling HttpResponse.json with an errors array and { status: 500 } tells Apollo Client a network error occurred, which lands in error.networkError rather than error.graphQLErrors. Reserve non-200 statuses for genuine transport failures; use the errors array with a 200 for resolver-level failures.

  • Mutating shared in-memory state leaks between tests. The catalog object above persists across a test file. Reset it in afterEach (or re-import a fresh copy) so a mutation in one test does not change the data another test reads. This ordering concern is the same one covered under advanced MSW handler patterns for stateful REST handlers.


FAQ

Should a GraphQL error return an HTTP error status?

No. A field-level GraphQL error is returned with HTTP 200 and an errors array in the body, because the transport succeeded. Only return a non-200 status when you are simulating a transport failure such as an unreachable server or a gateway timeout. Sending errors at a 500 misroutes the failure into a client’s networkError bucket instead of graphQLErrors.

How do I return partial data alongside an error?

Include both data and errors in the same response object. GraphQL allows a resolver to succeed for some fields and fail for others, so data.user can be populated while errors describes the failure of data.user.avatar. The path field in the error tells the client which selection failed, and a well-built client renders the successful parts of the tree while routing the failed path to an error boundary.


← Back to GraphQL Mocking Setup