GraphQL Testing Fundamentals: Queries, Mutations, and Subscriptions

GraphQL Testing Fundamentals: Queries, Mutations, and Subscriptions

GraphQL isn't just REST with a different syntax. It's a different execution model — one where the client controls the shape of the response, multiple operations can be batched, and errors can coexist with valid data in the same response. Those differences change what "testing" means.

This guide covers GraphQL testing fundamentals: what to test, how the execution model affects your strategy, and concrete patterns for queries, mutations, and subscriptions.

Why GraphQL Testing Is Different

With REST, the server owns the response shape. You test endpoints and verify status codes. With GraphQL, the client sends a document — a query or mutation — and the server resolves it field by field. This creates several unique challenges:

Partial success is valid. A GraphQL response can have both data and errors populated simultaneously. A query that fetches a user and their orders might return the user but fail on orders — and that's still HTTP 200. Your tests must check the errors array, not just the status code.

The schema is the contract. The SDL (Schema Definition Language) defines every possible operation. Tests can validate against this contract explicitly.

N+1 problems hide at test time. GraphQL's resolver model makes it easy to accidentally trigger N+1 database queries. Unit tests won't catch this — only integration tests with real query traces will.

Variables change behavior. A query with first: 10 vs first: 1000 can hit completely different code paths. Variable coverage is as important as operation coverage.

What to Test

Break GraphQL testing into four layers:

  1. Schema validation — does the schema itself match your intent?
  2. Resolver unit tests — does each resolver return the right data in isolation?
  3. Operation integration tests — does a full query/mutation against a real server work end-to-end?
  4. Subscription tests — do real-time events flow correctly to subscribers?

Don't skip layers. Unit tests catch resolver logic bugs fast. Integration tests catch query planning and authorization failures. Both are necessary.

Testing Queries

A GraphQL query test has three parts: the operation document, the variables, and the expected response shape.

// Using graphql-request
import { GraphQLClient, gql } from 'graphql-request'

const client = new GraphQLClient('http://localhost:4000/graphql')

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        id
        title
      }
    }
  }
`

describe('user query', () => {
  it('returns user with posts', async () => {
    const data = await client.request(GET_USER, { id: '1' })

    expect(data.user).toMatchObject({
      id: '1',
      name: expect.any(String),
      email: expect.stringContaining('@'),
    })
    expect(data.user.posts).toBeInstanceOf(Array)
  })

  it('returns null for non-existent user', async () => {
    const data = await client.request(GET_USER, { id: 'nonexistent' })
    expect(data.user).toBeNull()
  })

  it('handles missing required variable', async () => {
    await expect(client.request(GET_USER, {}))
      .rejects.toThrow('Variable "$id" of required type "ID!" was not provided')
  })
})

Testing Error Responses

This is where most teams fail. GraphQL errors don't always cause request failures — they're returned in the response body.

import { request } from 'graphql-request'

async function queryWithErrors(document, variables) {
  try {
    const data = await request('http://localhost:4000/graphql', document, variables)
    return { data, errors: null }
  } catch (error) {
    // graphql-request throws on errors by default
    // Some implementations include partial data in the error
    return {
      data: error.response?.data ?? null,
      errors: error.response?.errors ?? [error]
    }
  }
}

it('returns error for unauthorized field', async () => {
  const { data, errors } = await queryWithErrors(gql`
    query {
      adminDashboard {
        totalRevenue
      }
    }
  `)

  expect(errors).not.toBeNull()
  expect(errors[0].message).toMatch(/not authorized/i)
  // data may be null or partial — assert what you expect
  expect(data?.adminDashboard).toBeNull()
})

Aliased Fields and Fragments

Test queries that use aliases and fragments — they're common in production clients and can expose resolver aliasing bugs.

const ALIASED_QUERY = gql`
  query {
    currentUser: user(id: "me") {
      ...UserFields
    }
    adminUser: user(id: "admin") {
      ...UserFields
    }
  }

  fragment UserFields on User {
    id
    name
    role
  }
`

it('resolves aliased fields independently', async () => {
  const data = await client.request(ALIASED_QUERY)
  expect(data.currentUser.id).not.toBe(data.adminUser.id)
})

Testing Mutations

Mutations change state. Every mutation test should verify two things: the return value and the actual state change.

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      author {
        id
        name
      }
      createdAt
    }
  }
`

const GET_POST = gql`
  query GetPost($id: ID!) {
    post(id: $id) {
      id
      title
    }
  }
`

describe('createPost mutation', () => {
  it('creates post and returns it', async () => {
    const result = await client.request(CREATE_POST, {
      input: { title: 'Test Post', body: 'Content here', authorId: '1' }
    })

    expect(result.createPost).toMatchObject({
      id: expect.any(String),
      title: 'Test Post',
      author: { id: '1' },
    })
    expect(result.createPost.createdAt).toBeTruthy()
  })

  it('state change persists after mutation', async () => {
    const created = await client.request(CREATE_POST, {
      input: { title: 'Persistent Post', body: 'Body', authorId: '1' }
    })
    const postId = created.createPost.id

    // Verify it exists via a separate query
    const fetched = await client.request(GET_POST, { id: postId })
    expect(fetched.post.title).toBe('Persistent Post')
  })

  it('rejects invalid input', async () => {
    await expect(
      client.request(CREATE_POST, {
        input: { title: '', body: 'Content', authorId: '1' } // empty title
      })
    ).rejects.toThrow()
  })

  it('is idempotent with client mutation id', async () => {
    // If your schema supports idempotency keys
    const input = { title: 'Idempotent Post', body: 'Body', authorId: '1', clientMutationId: 'abc-123' }
    const first = await client.request(CREATE_POST, { input })
    const second = await client.request(CREATE_POST, { input })
    expect(first.createPost.id).toBe(second.createPost.id)
  })
})

Testing Mutation Rollbacks

Critical for financial or multi-step mutations: verify that partial failures don't leave data in a corrupt state.

const TRANSFER_FUNDS = gql`
  mutation TransferFunds($from: ID!, $to: ID!, $amount: Float!) {
    transferFunds(from: $from, to: $to, amount: $amount) {
      success
      fromBalance
      toBalance
    }
  }
`

it('rolls back on insufficient funds', async () => {
  const initialFromBalance = await getBalance('user-1')
  const initialToBalance = await getBalance('user-2')

  await expect(
    client.request(TRANSFER_FUNDS, {
      from: 'user-1',
      to: 'user-2',
      amount: 999999 // exceeds balance
    })
  ).rejects.toThrow()

  // Verify balances unchanged — transaction rolled back
  expect(await getBalance('user-1')).toBe(initialFromBalance)
  expect(await getBalance('user-2')).toBe(initialToBalance)
})

Testing Subscriptions

Subscriptions are GraphQL's real-time mechanism. They use WebSocket connections (typically via graphql-ws or subscriptions-transport-ws) and require different testing tooling.

import { createClient } from 'graphql-ws'
import WebSocket from 'ws'

function createSubscriptionClient(url) {
  return createClient({ url, webSocketImpl: WebSocket })
}

const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageCreated($channelId: ID!) {
    messageCreated(channelId: $channelId) {
      id
      text
      author {
        name
      }
      createdAt
    }
  }
`

async function collectMessages(client, document, variables, count, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const messages = []
    const timer = setTimeout(() => {
      unsubscribe()
      reject(new Error(`Timeout: only received ${messages.length}/${count} messages`))
    }, timeoutMs)

    const unsubscribe = client.subscribe(
      { query: document, variables },
      {
        next: (data) => {
          messages.push(data)
          if (messages.length >= count) {
            clearTimeout(timer)
            unsubscribe()
            resolve(messages)
          }
        },
        error: (err) => {
          clearTimeout(timer)
          reject(err)
        },
        complete: () => {
          clearTimeout(timer)
          resolve(messages)
        },
      }
    )
  })
}

describe('messageCreated subscription', () => {
  let wsClient

  beforeEach(() => {
    wsClient = createSubscriptionClient('ws://localhost:4000/graphql')
  })

  afterEach(async () => {
    await wsClient.dispose()
  })

  it('receives message when published to channel', async () => {
    const messagesPromise = collectMessages(
      wsClient,
      MESSAGE_SUBSCRIPTION,
      { channelId: 'channel-1' },
      1
    )

    // Trigger the subscription event via a mutation
    await client.request(gql`
      mutation {
        sendMessage(channelId: "channel-1", text: "Hello world") {
          id
        }
      }
    `)

    const messages = await messagesPromise
    expect(messages[0].data.messageCreated.text).toBe('Hello world')
  })

  it('does not receive messages from other channels', async () => {
    const receivedMessages = []
    const unsubscribe = wsClient.subscribe(
      { query: MESSAGE_SUBSCRIPTION, variables: { channelId: 'channel-1' } },
      { next: (d) => receivedMessages.push(d) }
    )

    // Publish to a different channel
    await client.request(gql`
      mutation {
        sendMessage(channelId: "channel-2", text: "Other channel") {
          id
        }
      }
    `)

    // Wait briefly then verify no messages received
    await new Promise(r => setTimeout(r, 500))
    expect(receivedMessages).toHaveLength(0)
    unsubscribe()
  })
})

Testing Authorization in GraphQL

Authorization in GraphQL is per-field, not per-endpoint. A logged-in user might be able to query user.name but not user.ssn. Test this explicitly.

function createAuthenticatedClient(token) {
  return new GraphQLClient('http://localhost:4000/graphql', {
    headers: { Authorization: `Bearer ${token}` }
  })
}

describe('field-level authorization', () => {
  it('allows reading public fields without auth', async () => {
    const publicClient = new GraphQLClient('http://localhost:4000/graphql')
    const data = await publicClient.request(gql`
      query { user(id: "1") { name } }
    `)
    expect(data.user.name).toBeTruthy()
  })

  it('blocks private fields without auth', async () => {
    const publicClient = new GraphQLClient('http://localhost:4000/graphql')
    await expect(
      publicClient.request(gql`
        query { user(id: "1") { email ssn } }
      `)
    ).rejects.toMatchObject({
      response: {
        errors: expect.arrayContaining([
          expect.objectContaining({ message: expect.stringMatching(/not authorized/i) })
        ])
      }
    })
  })

  it('allows admin to read private fields', async () => {
    const adminToken = await getAdminToken()
    const adminClient = createAuthenticatedClient(adminToken)
    const data = await adminClient.request(gql`
      query { user(id: "1") { email ssn } }
    `)
    expect(data.user.email).toBeTruthy()
  })
})

Depth Limiting and Query Complexity

Most production GraphQL APIs limit query depth and complexity to prevent abuse. Test these limits explicitly.

it('rejects queries that exceed depth limit', async () => {
  // A deeply nested query that exceeds the server's depth limit
  await expect(
    client.request(gql`
      query DeepQuery {
        user(id: "1") {
          posts {
            comments {
              author {
                posts {
                  comments {
                    author {
                      name
                    }
                  }
                }
              }
            }
          }
        }
      }
    `)
  ).rejects.toThrow(/query depth/i)
})

it('rejects queries with excessive complexity', async () => {
  // A wide query with high field count
  await expect(
    client.request(gql`
      query HighComplexity {
        users(first: 100) {
          posts(first: 100) {
            comments(first: 100) {
              text
            }
          }
        }
      }
    `)
  ).rejects.toThrow(/query complexity/i)
})

Test Data Strategy

GraphQL tests need consistent, known data. Three approaches:

Database seeding: Run a seed script before each test suite that creates known records. Fast, but requires database cleanup between tests.

In-memory mocking: Use graphql-tools makeExecutableSchema with custom resolvers that return fixture data. Zero I/O, but doesn't catch database-level bugs.

Snapshot fixtures: Record real API responses and store them as fixtures. Fast and realistic, but requires periodic refresh.

For integration tests, database seeding is the most trustworthy approach:

beforeAll(async () => {
  await db.seed([
    { type: 'user', id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
    { type: 'user', id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
    { type: 'post', id: '10', title: 'First Post', authorId: '1' },
  ])
})

afterAll(async () => {
  await db.cleanup()
})

What to Skip

Not everything needs an integration test. Skip integration tests for:

  • Pure computation in resolvers (test the function directly)
  • Format transformations (dates, currencies)
  • Pagination math

Do write integration tests for:

  • Authorization rules
  • Multi-resolver operations (anything with nested types)
  • Mutations that touch multiple tables
  • Subscriptions (real-time behavior can't be unit tested meaningfully)

Summary

GraphQL testing requires deliberate strategy. The key shifts from REST testing:

  1. Always check both data and errors — partial success is valid
  2. Test mutations for state persistence, not just return values
  3. Subscriptions need dedicated WebSocket test infrastructure
  4. Authorization is per-field — test field boundaries explicitly
  5. Query depth and complexity limits are security controls — verify they work

Start with integration tests for your most critical operations. Add unit tests for resolver logic that contains real business rules. That coverage combination catches the bugs that matter.

Read more

Start now free