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:
- Schema validation — does the schema itself match your intent?
- Resolver unit tests — does each resolver return the right data in isolation?
- Operation integration tests — does a full query/mutation against a real server work end-to-end?
- 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:
- Always check both
dataanderrors— partial success is valid - Test mutations for state persistence, not just return values
- Subscriptions need dedicated WebSocket test infrastructure
- Authorization is per-field — test field boundaries explicitly
- 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.