Back to Blog

GraphQL Testing: How to Test Queries, Mutations, and Schemas Without Breaking Clients

Master GraphQL testing with strategies for query validation, mutation testing, schema testing, error handling, and integration with modern testing frameworks.

Emily Rodriguez

QA Automation Engineer

Published

8 min read

Reading time

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

  1. GraphQL Testing Challenges
  2. Schema Validation and Type Testing
  3. Query Testing Strategies
  4. Mutation Testing
  5. Subscription Testing
  6. Error Handling and Edge Cases
  7. Integration with Playwright
  8. Performance and Security Testing
  9. 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.

Related Posts