GraphQL Testing: How to Test Queries, Mutations, and Schemas Without Breaking Clients
GraphQL promised a single endpoint to rule them all. No more REST endpoint proliferation, no more over-fetching or under-fetching. But with great power comes great testing complexity. How do you validate a flexible query language that can request anything, in any combination?
Testing GraphQL isn't just about hitting an endpoint and checking status codes. You need to validate schemas, test query depth limits, handle complex nested resolvers, and ensure your mutations maintain data integrity—all while dealing with a type system that's more sophisticated than most REST APIs.
This guide provides battle-tested strategies for comprehensive GraphQL testing, from schema validation to complex mutation scenarios.
Table of Contents
- GraphQL Testing Challenges
- Schema Validation and Type Testing
- Query Testing Strategies
- Mutation Testing
- Subscription Testing
- Error Handling and Edge Cases
- Integration with Playwright
- Performance and Security Testing
- Best Practices
GraphQL Testing Challenges
What Makes GraphQL Different?
| Aspect | REST | GraphQL | Testing Impact |
|---|---|---|---|
| Endpoints | Multiple | Single | Need to test query variations, not endpoints |
| Over-fetching | Common | Rare | Must validate exact field returns |
| Type System | Optional (OpenAPI) | Built-in | Schema validation crucial |
| Error Format | HTTP codes | Errors array | Different error handling |
| Query Depth | N/A | Configurable | Need depth attack tests |
| Caching | HTTP-based | Field-level | Complex cache invalidation testing |
Common GraphQL Testing Anti-Patterns
❌ Testing only happy paths
# Not enough!
query {
user(id: "123") {
name
}
}
✅ Test error cases, nested queries, and edge cases
query {
user(id: "nonexistent") {
name
}
user(id: "123") {
name
posts(first: 1000000) {
# Pagination limits
edges {
node {
comments {
# N+1 query issues
author {
posts {
title
}
} # Deep nesting
}
}
}
}
}
}
Schema Validation and Type Testing
Automated Schema Testing
// tests/graphql/schema.spec.ts
import { buildSchema, printSchema } from 'graphql';
import { readFileSync } from 'fs';
import { test, expect } from '@playwright/test';
test.describe('GraphQL Schema Validation', () => {
const schemaFile = readFileSync('./schema.graphql', 'utf-8');
let schema: any;
test.beforeAll(() => {
schema = buildSchema(schemaFile);
});
test('schema is valid', () => {
expect(schema).toBeDefined();
expect(() => printSchema(schema)).not.toThrow();
});
test('required types exist', () => {
const typeMap = schema.getTypeMap();
const requiredTypes = ['Query', 'Mutation', 'User', 'Post', 'Comment'];
for (const typeName of requiredTypes) {
expect(typeMap[typeName]).toBeDefined();
}
});
test('User type has required fields', () => {
const userType = schema.getType('User');
const fields = userType.getFields();
expect(fields.id).toBeDefined();
expect(fields.email).toBeDefined();
expect(fields.name).toBeDefined();
expect(fields.posts).toBeDefined();
// Verify field types
expect(fields.id.type.toString()).toBe('ID!');
expect(fields.email.type.toString()).toBe('String!');
expect(fields.posts.type.toString()).toContain('[Post');
});
test('deprecated fields are marked', () => {
const userType = schema.getType('User');
const fields = userType.getFields();
if (fields.oldField) {
expect(fields.oldField.deprecationReason).toBeDefined();
}
});
});
Schema Diff Detection
// scripts/check-schema-changes.ts
import { buildSchema, printSchema, diff } from 'graphql';
interface SchemaChange {
type: 'BREAKING' | 'DANGEROUS' | 'SAFE';
description: string;
}
async function detectSchemaChanges(oldSchemaPath: string, newSchemaPath: string): Promise<SchemaChange[]> {
const oldSchema = buildSchema(readFileSync(oldSchemaPath, 'utf-8'));
const newSchema = buildSchema(readFileSync(newSchemaPath, 'utf-8'));
const changes: SchemaChange[] = [];
const differences = diff(oldSchema, newSchema);
for (const change of differences) {
// Categorize changes
const changeType = categorizeChange(change);
changes.push({
type: changeType,
description: change.message,
});
}
return changes;
}
function categorizeChange(change: any): 'BREAKING' | 'DANGEROUS' | 'SAFE' {
const breakingPatterns = [
/field .* was removed/i,
/type .* was removed/i,
/argument .* was removed/i,
/changed type from .* to/i,
];
const dangerousPatterns = [/added a new required argument/i, /changed field .* to nullable/i];
const message = change.message.toLowerCase();
if (breakingPatterns.some((pattern) => pattern.test(message))) {
return 'BREAKING';
}
if (dangerousPatterns.some((pattern) => pattern.test(message))) {
return 'DANGEROUS';
}
return 'SAFE';
}
// Use in CI
test('no breaking schema changes', async () => {
const changes = await detectSchemaChanges('./schema-main.graphql', './schema.graphql');
const breakingChanges = changes.filter((c) => c.type === 'BREAKING');
expect(breakingChanges).toHaveLength(0);
});
Query Testing Strategies
Basic Query Testing
// tests/graphql/queries.spec.ts
import { test, expect } from '@playwright/test';
test.describe('GraphQL Queries', () => {
test('fetches user by ID', async ({ request }) => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
createdAt
}
}
`;
const response = await request.post('/graphql', {
data: {
query,
variables: { id: '123' },
},
headers: {
Authorization: 'Bearer test-token',
},
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.errors).toBeUndefined();
expect(data.data.user).toMatchObject({
id: '123',
name: expect.any(String),
email: expect.stringMatching(/@/),
createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}/),
});
});
test('handles non-existent user', async ({ request }) => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
const response = await request.post('/graphql', {
data: {
query,
variables: { id: 'nonexistent' },
},
});
const data = await response.json();
// GraphQL returns 200 even for errors!
expect(response.status()).toBe(200);
expect(data.errors).toBeDefined();
expect(data.errors[0].message).toContain('User not found');
expect(data.errors[0].extensions?.code).toBe('USER_NOT_FOUND');
});
});
Testing Nested Queries
test('fetches nested user data', async ({ request }) => {
const query = `
query GetUserWithPosts($userId: ID!, $postLimit: Int) {
user(id: $userId) {
id
name
posts(first: $postLimit) {
edges {
node {
id
title
content
comments(first: 5) {
edges {
node {
id
text
author {
id
name
}
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
const response = await request.post('/graphql', {
data: {
query,
variables: {
userId: '123',
postLimit: 10,
},
},
});
const data = await response.json();
expect(data.errors).toBeUndefined();
expect(data.data.user.posts.edges).toBeDefined();
expect(data.data.user.posts.edges.length).toBeLessThanOrEqual(10);
// Verify nested structure
const firstPost = data.data.user.posts.edges[0]?.node;
if (firstPost) {
expect(firstPost).toHaveProperty('id');
expect(firstPost).toHaveProperty('title');
expect(firstPost.comments.edges).toBeInstanceOf(Array);
const firstComment = firstPost.comments.edges[0]?.node;
if (firstComment) {
expect(firstComment.author).toHaveProperty('id');
expect(firstComment.author).toHaveProperty('name');
}
}
});
Fragment Testing
// tests/graphql/fragments.spec.ts
test('uses fragments correctly', async ({ request }) => {
const query = `
fragment UserFields on User {
id
name
email
avatar
}
fragment PostFields on Post {
id
title
content
author {
...UserFields
}
}
query GetPosts {
posts(first: 10) {
edges {
node {
...PostFields
comments(first: 3) {
edges {
node {
id
text
author {
...UserFields
}
}
}
}
}
}
}
}
`;
const response = await request.post('/graphql', {
data: { query },
});
const data = await response.json();
expect(data.errors).toBeUndefined();
const firstPost = data.data.posts.edges[0]?.node;
expect(firstPost.author).toMatchObject({
id: expect.any(String),
name: expect.any(String),
email: expect.stringMatching(/@/),
avatar: expect.any(String),
});
});
Mutation Testing
Create Mutation
// tests/graphql/mutations.spec.ts
test('creates new post', async ({ request }) => {
const mutation = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
content
author {
id
name
}
createdAt
}
errors {
field
message
}
}
}
`;
const response = await request.post('/graphql', {
data: {
query: mutation,
variables: {
input: {
title: 'Test Post',
content: 'This is a test post content',
tags: ['testing', 'graphql'],
},
},
},
headers: {
Authorization: 'Bearer valid-token',
},
});
const data = await response.json();
expect(data.errors).toBeUndefined();
expect(data.data.createPost.errors).toBeNull();
expect(data.data.createPost.post).toMatchObject({
id: expect.any(String),
title: 'Test Post',
content: 'This is a test post content',
author: {
id: expect.any(String),
name: expect.any(String),
},
createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}/),
});
});
Update Mutation with Optimistic Response
test('updates post with optimistic UI', async ({ page, request }) => {
await page.goto('/posts/123');
// Intercept GraphQL request
await page.route('**/graphql', async (route) => {
const postData = await route.request().postData();
const { query, variables } = JSON.parse(postData!);
if (query.includes('updatePost')) {
// Simulate slow network
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
updatePost: {
post: {
id: variables.input.id,
title: variables.input.title,
content: variables.input.content,
updatedAt: new Date().toISOString(),
},
errors: null,
},
},
}),
});
} else {
await route.continue();
}
});
// Click edit
await page.click('[data-test="edit-post"]');
// Update title
await page.fill('[data-test="post-title"]', 'Updated Title');
await page.click('[data-test="save"]');
// Optimistic update should be immediately visible
await expect(page.locator('[data-test="post-title-display"]')).toContainText('Updated Title');
// Even while request is in flight
await expect(page.locator('[data-test="saving-indicator"]')).toBeVisible();
});
Delete Mutation with Error Handling
test('handles delete errors gracefully', async ({ request }) => {
const mutation = `
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
success
errors {
message
code
}
}
}
`;
const response = await request.post('/graphql', {
data: {
query: mutation,
variables: { id: 'post-owned-by-someone-else' },
},
headers: {
Authorization: 'Bearer user-token',
},
});
const data = await response.json();
expect(data.errors).toBeUndefined();
expect(data.data.deletePost.success).toBe(false);
expect(data.data.deletePost.errors).toEqual([
{
message: 'You do not have permission to delete this post',
code: 'FORBIDDEN',
},
]);
});
Subscription Testing
// tests/graphql/subscriptions.spec.ts
import { createClient } from 'graphql-ws';
import { WebSocket } from 'ws';
test('receives real-time updates via subscription', async () => {
const client = createClient({
url: 'ws://localhost:4000/graphql',
webSocketImpl: WebSocket,
});
const subscription = `
subscription OnPostCreated {
postCreated {
id
title
author {
name
}
}
}
`;
const receivedPosts: any[] = [];
// Subscribe
const unsubscribe = client.subscribe(
{ query: subscription },
{
next: (data) => {
receivedPosts.push(data.data.postCreated);
},
error: (error) => {
console.error('Subscription error:', error);
},
complete: () => {
console.log('Subscription complete');
},
},
);
// Wait a bit for subscription to be established
await new Promise((resolve) => setTimeout(resolve, 500));
// Trigger mutation that should fire subscription
await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation {
createPost(input: { title: "Test Post", content: "Content" }) {
post { id title }
}
}
`,
}),
});
// Wait for subscription to receive data
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(receivedPosts).toHaveLength(1);
expect(receivedPosts[0]).toMatchObject({
id: expect.any(String),
title: 'Test Post',
author: {
name: expect.any(String),
},
});
unsubscribe();
});
Error Handling and Edge Cases
Testing Query Depth Limits
test('prevents deeply nested queries', async ({ request }) => {
// Malicious deep query
const deepQuery = `
query {
user(id: "123") {
posts {
edges {
node {
comments {
edges {
node {
author {
posts {
edges {
node {
comments {
edges {
node {
author {
posts {
edges {
node {
title
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
`;
const response = await request.post('/graphql', {
data: { query: deepQuery },
});
const data = await response.json();
expect(data.errors).toBeDefined();
expect(data.errors[0].message).toContain('Query depth limit exceeded');
});
Testing Query Complexity
test('prevents overly complex queries', async ({ request }) => {
const complexQuery = `
query {
users(first: 1000) {
edges {
node {
posts(first: 1000) {
edges {
node {
comments(first: 1000) {
edges {
node {
author {
id
}
}
}
}
}
}
}
}
}
}
}
`;
const response = await request.post('/graphql', {
data: { query: complexQuery },
});
const data = await response.json();
expect(data.errors).toBeDefined();
expect(data.errors[0].extensions?.code).toBe('QUERY_TOO_COMPLEX');
});
Testing Rate Limiting
test('enforces rate limits', async ({ request }) => {
const query = `query { currentUser { id } }`;
// Make 100 requests rapidly
const requests = Array.from({ length: 100 }, () => request.post('/graphql', { data: { query } }));
const responses = await Promise.all(requests);
const rateLimitedResponses = responses.filter(async (res) => {
const data = await res.json();
return data.errors?.[0]?.extensions?.code === 'RATE_LIMIT_EXCEEDED';
});
expect(rateLimitedResponses.length).toBeGreaterThan(0);
});
Integration with Playwright
Helper Functions
// lib/graphql-helpers.ts
import { APIRequestContext } from '@playwright/test';
export class GraphQLClient {
constructor(
private request: APIRequestContext,
private endpoint: string = '/graphql',
) {}
async query<T = any>(
query: string,
variables?: Record<string, any>,
headers?: Record<string, string>,
): Promise<{ data?: T; errors?: any[] }> {
const response = await this.request.post(this.endpoint, {
data: { query, variables },
headers,
});
return await response.json();
}
async mutation<T = any>(
mutation: string,
variables?: Record<string, any>,
headers?: Record<string, string>,
): Promise<{ data?: T; errors?: any[] }> {
return this.query<T>(mutation, variables, headers);
}
async authenticatedQuery<T = any>(
query: string,
token: string,
variables?: Record<string, any>,
): Promise<{ data?: T; errors?: any[] }> {
return this.query<T>(query, variables, {
Authorization: `Bearer ${token}`,
});
}
}
// Usage in tests
test('use GraphQL client helper', async ({ request }) => {
const gql = new GraphQLClient(request);
const { data, errors } = await gql.authenticatedQuery(`query { currentUser { id name } }`, 'test-token');
expect(errors).toBeUndefined();
expect(data.currentUser).toBeDefined();
});
Performance and Security Testing
N+1 Query Detection
test('detects N+1 query problems', async ({ request }) => {
// Enable query logging
const startTime = Date.now();
const query = `
query {
posts(first: 100) {
edges {
node {
id
title
author {
# This could cause N+1 if not using DataLoader
name
email
}
}
}
}
}
`;
const response = await request.post('/graphql', {
data: { query },
});
const duration = Date.now() - startTime;
// Should complete quickly if using DataLoader
expect(duration).toBeLessThan(1000);
// Check extensions for query count (if backend provides it)
const data = await response.json();
const queryCount = data.extensions?.queryCount;
if (queryCount) {
// Should be ~2 queries (posts + batch author fetch), not 101
expect(queryCount).toBeLessThan(5);
}
});
Best Practices
GraphQL Testing Checklist
| Test Type | Coverage | Priority |
|---|---|---|
| Schema validation | 100% of types | 🔴 Critical |
| Required fields | All non-null fields | 🔴 Critical |
| Query happy paths | Core queries | 🔴 Critical |
| Mutation happy paths | All mutations | 🔴 Critical |
| Error cases | 404, 403, 400 | 🟡 High |
| Nested queries | 3+ levels deep | 🟡 High |
| Pagination | First, last, after, before | 🟡 High |
| Subscriptions | Real-time updates | 🟢 Medium |
| Rate limiting | Security | 🟢 Medium |
| Query depth limits | Security | 🟢 Medium |
| Performance | N+1 queries | 🟢 Medium |
Reusable Test Patterns
// tests/helpers/graphql-test-suite.ts
export function createGraphQLTestSuite(
resourceName: string,
config: {
createMutation: string;
updateMutation: string;
deleteMutation: string;
getQuery: string;
listQuery: string;
},
) {
test.describe(`${resourceName} GraphQL Operations`, () => {
test(`creates ${resourceName}`, async ({ request }) => {
// Generic create test
});
test(`updates ${resourceName}`, async ({ request }) => {
// Generic update test
});
test(`deletes ${resourceName}`, async ({ request }) => {
// Generic delete test
});
test(`fetches single ${resourceName}`, async ({ request }) => {
// Generic get test
});
test(`lists ${resourceName} with pagination`, async ({ request }) => {
// Generic list test
});
});
}
// Usage
createGraphQLTestSuite('Post', {
createMutation: 'createPost',
updateMutation: 'updatePost',
deleteMutation: 'deletePost',
getQuery: 'post',
listQuery: 'posts',
});
Conclusion
GraphQL testing requires a fundamentally different approach than REST API testing. Focus on schema validation, test your query and mutation patterns thoroughly, and don't forget to test error cases, rate limits, and query complexity guards.
The flexibility of GraphQL is both its strength and its testing challenge. By implementing the strategies in this guide—from schema validation to complex nested queries and subscription testing—you can build confidence that your GraphQL API behaves correctly under all conditions.
Related articles: Also see REST and GraphQL API testing best practices compared, contract testing that spans both REST and GraphQL service boundaries, and microservice testing challenges GraphQL schemas often expose.
Ready to streamline your GraphQL testing? Try ScanlyApp for comprehensive API testing with built-in GraphQL support, schema validation, and automated mutation testing. Start free—no credit card required.
