GraphQL API Testing: Queries, Mutations, and Subscriptions
GraphQL testing differs from REST testing because a single endpoint handles all operations, errors return HTTP 200, and subscriptions require WebSocket connections. This guide covers the practical testing strategies for queries, mutations, and subscriptions — from unit-level resolver tests to full integration tests against a running server.
Key Takeaways
GraphQL errors return HTTP 200. Don't test status codes for GraphQL errors — test the errors array in the response body instead.
Test queries by shape, not exact equality. Use partial matching to assert on the fields you care about; your schema will evolve.
Mutations need before/after state. A mutation test must verify that the database (or state) actually changed — not just that the response looks right.
Subscriptions require real async handling. Use event emitters or pub/sub mocks and async/await with appropriate timeouts.
Separate resolver logic from transport. Unit test resolver functions directly; integration test the full HTTP/WebSocket layer separately.
Why GraphQL Testing Is Different
REST APIs map operations to HTTP verbs and endpoints. GraphQL collapses everything to POST /graphql. This changes how you test:
- Status codes are unreliable. A query that fails authorization returns
200 OKwith{ errors: [...] }. - The shape of the response is variable. Clients request exactly the fields they need, so the same resolver can return different shapes.
- Subscriptions are stateful. Unlike queries, subscriptions maintain WebSocket connections and push multiple events.
These differences mean you need a testing strategy that goes beyond expect(res.status).toBe(200).
Setting Up a Testable GraphQL Server
The most testable GraphQL setups separate schema definition from server transport:
// schema.js
const { buildSchema } = require('graphql');
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
deleteUser(id: ID!): Boolean!
}
`;// resolvers.js
const db = require('./db');
const resolvers = {
Query: {
user: async (_, { id }) => db.users.findById(id),
users: async () => db.users.findAll(),
},
Mutation: {
createUser: async (_, { name, email }) => {
return db.users.create({ name, email });
},
deleteUser: async (_, { id }) => {
await db.users.delete(id);
return true;
},
},
};This separation lets you unit test resolvers without spinning up an HTTP server.
Testing Queries
Unit Testing Resolvers
Test resolver functions in isolation, injecting a mock database:
const { resolvers } = require('./resolvers');
describe('Query.user', () => {
it('returns the user when found', async () => {
const mockDb = {
users: {
findById: jest.fn().mockResolvedValue({
id: '1',
name: 'Alice',
email: 'alice@example.com',
}),
},
};
// Inject mock context
const result = await resolvers.Query.user(null, { id: '1' }, { db: mockDb });
expect(result).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
expect(mockDb.users.findById).toHaveBeenCalledWith('1');
});
it('returns null when user not found', async () => {
const mockDb = {
users: { findById: jest.fn().mockResolvedValue(null) },
};
const result = await resolvers.Query.user(null, { id: '999' }, { db: mockDb });
expect(result).toBeNull();
});
});Integration Testing Queries Over HTTP
Use supertest with your GraphQL server to test end-to-end query execution:
const request = require('supertest');
const app = require('./app'); // your Express/Apollo app
describe('GraphQL Query Integration', () => {
it('fetches a user by id', async () => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
const response = await request(app)
.post('/graphql')
.send({ query, variables: { id: '1' } })
.expect(200); // GraphQL always returns 200
expect(response.body.errors).toBeUndefined();
expect(response.body.data.user).toMatchObject({
id: '1',
name: expect.any(String),
email: expect.stringContaining('@'),
});
});
it('returns error for missing id argument', async () => {
const query = `query { user { id name } }`; // missing required arg
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toMatch(/argument.*id/i);
});
});Testing Mutations
Mutations require state verification — assert both the response AND the side effect:
describe('Mutation.createUser', () => {
beforeEach(async () => {
await db.users.deleteAll(); // clean state before each test
});
it('creates a user and returns it', async () => {
const mutation = `
mutation CreateUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) {
id
name
email
}
}
`;
const response = await request(app)
.post('/graphql')
.send({
query: mutation,
variables: { name: 'Bob', email: 'bob@example.com' },
})
.expect(200);
expect(response.body.errors).toBeUndefined();
const { createUser } = response.body.data;
expect(createUser.id).toBeDefined();
expect(createUser.name).toBe('Bob');
expect(createUser.email).toBe('bob@example.com');
// Verify the side effect — the user actually exists in DB
const storedUser = await db.users.findById(createUser.id);
expect(storedUser).not.toBeNull();
expect(storedUser.email).toBe('bob@example.com');
});
it('rejects duplicate email', async () => {
await db.users.create({ name: 'Alice', email: 'alice@example.com' });
const mutation = `
mutation {
createUser(name: "Alice2", email: "alice@example.com") {
id
}
}
`;
const response = await request(app)
.post('/graphql')
.send({ query: mutation })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toMatch(/email.*taken|duplicate/i);
});
});Testing Subscriptions
Subscriptions use WebSockets. Testing them requires handling async event streams:
const { createClient } = require('graphql-ws');
const WebSocket = require('ws');
describe('Subscription.userCreated', () => {
let client;
beforeEach(() => {
client = createClient({
url: 'ws://localhost:4000/graphql',
webSocketImpl: WebSocket,
});
});
afterEach(() => client.dispose());
it('receives event when user is created', async () => {
const events = [];
await new Promise((resolve, reject) => {
const subscription = `
subscription {
userCreated {
id
name
}
}
`;
client.subscribe({ query: subscription }, {
next: (data) => {
events.push(data);
if (events.length >= 1) resolve();
},
error: reject,
complete: resolve,
});
// Trigger the event by creating a user
setTimeout(async () => {
await request(app)
.post('/graphql')
.send({
query: `mutation { createUser(name: "Eve", email: "eve@test.com") { id } }`,
});
}, 100);
});
expect(events).toHaveLength(1);
expect(events[0].data.userCreated.name).toBe('Eve');
});
});Testing Authorization
GraphQL authorization should be tested explicitly, not assumed:
describe('Authorization', () => {
it('returns error for unauthenticated users on protected queries', async () => {
const query = `query { adminUsers { id email } }`;
const response = await request(app)
.post('/graphql')
.send({ query })
// No Authorization header
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
});
it('returns data for authenticated admin users', async () => {
const token = await getAdminToken();
const query = `query { adminUsers { id email } }`;
const response = await request(app)
.post('/graphql')
.set('Authorization', `Bearer ${token}`)
.send({ query })
.expect(200);
expect(response.body.errors).toBeUndefined();
expect(Array.isArray(response.body.data.adminUsers)).toBe(true);
});
});Error Handling Patterns
GraphQL errors come in two categories:
- Schema/validation errors — caught before resolvers run
- Resolver errors — thrown inside resolver logic
describe('Error handling', () => {
it('returns GRAPHQL_VALIDATION_FAILED for unknown fields', async () => {
const response = await request(app)
.post('/graphql')
.send({ query: `query { user(id: "1") { nonExistentField } }` })
.expect(200);
expect(response.body.errors[0].extensions.code).toBe('GRAPHQL_VALIDATION_FAILED');
});
it('does not expose internal error details', async () => {
// Force a resolver to throw
jest.spyOn(db.users, 'findById').mockRejectedValueOnce(new Error('DB connection lost'));
const response = await request(app)
.post('/graphql')
.send({ query: `query { user(id: "1") { id } }` })
.expect(200);
expect(response.body.errors[0].message).not.toContain('DB connection lost');
expect(response.body.errors[0].message).toBe('Internal server error');
});
});Running GraphQL Tests in CI
HelpMeTest can run your GraphQL test suite automatically on every commit. With natural language test definitions, you can describe API contracts in plain English and have them verified 24/7:
"After creating a user with email alice@example.com, querying users should include that email in the results."