GraphQL API Testing Best Practices

Complete guide to testing GraphQL queries, mutations, subscriptions, and schemas

Published: January 2025 • 15 min read

GraphQL has revolutionized API development by giving clients precise control over the data they receive. But this flexibility introduces unique testing challenges. Unlike REST where endpoints are fixed, GraphQL allows infinite query combinations. How do you test that? Where do you even start?

This guide covers everything you need to test GraphQL APIs effectively: queries, mutations, subscriptions, schema validation, error handling, and performance testing. You'll learn practical strategies to catch bugs early and ensure your GraphQL API works reliably in production.

GraphQL Testing Fundamentals

What Makes GraphQL Different?

GraphQL fundamentally changes how you test APIs compared to REST.

REST API Testing:

  • • Fixed endpoints (GET /users, POST /users)
  • • Fixed response structure
  • • Multiple endpoints to test
  • • Over-fetching or under-fetching common

GraphQL API Testing:

  • • Single endpoint (POST /graphql)
  • • Dynamic response structure
  • • Infinite query combinations
  • • Client controls data shape

GraphQL Schema - Your Contract

The schema defines what's possible. Test that your API conforms to the schema.

# Example GraphQL Schema
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  published: Boolean!
  comments: [Comment!]!
}

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
  posts(authorId: ID): [Post!]!
}

type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String, email: String): User!
  deleteUser(id: ID!): Boolean!
  createPost(title: String!, content: String!): Post!
}

type Subscription {
  postAdded: Post!
  userUpdated(userId: ID!): User!
}

Testing GraphQL Queries

✓ Basic Query Testing

Start with simple queries and verify the response structure matches your expectations.

// Test: Fetch single user
POST /graphql
Content-Type: application/json

{
  "query": "{ user(id: \"123\") { id name email } }"
}

// Expected Response:
{
  "data": {
    "user": {
      "id": "123",
      "name": "John Doe",
      "email": "[email protected]"
    }
  }
}

// Test Cases:
// ✓ Returns correct user data
// ✓ Only returns requested fields (not all fields)
// ✓ Returns null for non-existent user
// ✓ Response matches schema types (id: ID!, name: String!)

✓ Testing Nested Queries

GraphQL's power is nested queries. Test that relationships are resolved correctly.

// Query with nested relationships
{
  "query": `
    {
      user(id: "123") {
        id
        name
        posts {
          id
          title
          comments {
            id
            text
            author {
              name
            }
          }
        }
      }
    }
  `
}

// Test Cases:
// ✓ User data is correct
// ✓ Posts array contains user's posts
// ✓ Each post has comments
// ✓ Each comment has author with name
// ✓ No N+1 query problem (check query count)
// ✓ Circular references handled (user > posts > author > posts...)

✓ Testing Query Arguments

Test that filtering, pagination, and sorting arguments work correctly.

// Pagination test
{
  "query": `
    {
      users(limit: 10, offset: 20) {
        id
        name
      }
    }
  `
}

// Test Cases:
// ✓ Returns exactly 10 users
// ✓ Skips first 20 users (offset works)
// ✓ limit: 0 returns empty array
// ✓ Negative limit/offset handled gracefully
// ✓ limit > max limit is capped

// Filtering test
{
  "query": `
    {
      posts(authorId: "123", published: true) {
        id
        title
        published
      }
    }
  `
}

// Test Cases:
// ✓ Only returns posts by author 123
// ✓ Only returns published posts
// ✓ Multiple filters work together (AND logic)
// ✓ Invalid authorId returns empty array

⚠The N+1 Query Problem

The most common GraphQL performance killer. One query triggers hundreds of database queries.

The Problem:

Query: Get 100 users and their posts

{
  users {
    name
    posts {  // Problem here!
      title
    }
  }
}

Result: 1 query for users + 100 queries for posts = 101 queries!

The Solution: DataLoader

Use DataLoader to batch and cache database queries:

  • ✓ Batches requests within single tick
  • ✓ Caches results within single request
  • ✓ 101 queries → 2 queries (1 for users, 1 batched for all posts)
// Test for N+1 problem
test('Should not cause N+1 queries', async () => {
  // Enable query logging
  const queryCount = 0;
  
  await graphql({
    schema,
    source: `
      {
        users {
          name
          posts { title }
        }
      }
    `
  });
  
  // Should be 2 queries max (users + posts batch)
  // Not 101 queries!
  expect(queryCount).toBeLessThan(5);
});

Testing GraphQL Mutations

✓ Testing Create Mutations

Mutations modify data. Test that changes actually happen and response is correct.

// Create user mutation
{
  "query": `
    mutation {
      createUser(name: "Jane Doe", email: "[email protected]") {
        id
        name
        email
        createdAt
      }
    }
  `
}

// Expected Response:
{
  "data": {
    "createUser": {
      "id": "456",
      "name": "Jane Doe",
      "email": "[email protected]",
      "createdAt": "2025-01-11T10:30:00Z"
    }
  }
}

// Test Cases:
// ✓ Returns created user with ID
// ✓ User actually created in database
// ✓ Can query for user after creation
// ✓ Required fields validated (name, email required)
// ✓ Email format validated
// ✓ Duplicate email rejected
// ✓ Returns meaningful error for invalid data

✓ Testing Update Mutations

Verify updates work correctly and only change specified fields.

// Update user mutation (partial update)
{
  "query": `
    mutation {
      updateUser(id: "123", name: "John Smith") {
        id
        name
        email
      }
    }
  `
}

// Test Cases:
// ✓ Name updated to "John Smith"
// ✓ Email unchanged (not provided in mutation)
// ✓ Returns 404 error for non-existent user
// ✓ Validates updated fields (email format if provided)
// ✓ Only user owner can update (authorization)
// ✓ Optimistic locking prevents concurrent update conflicts

✓ Testing Delete Mutations

Test deletion and verify data is actually removed (or soft-deleted).

// Delete user mutation
{
  "query": `
    mutation {
      deleteUser(id: "123")
    }
  `
}

// Expected Response:
{
  "data": {
    "deleteUser": true
  }
}

// Test Cases:
// ✓ Returns true on successful delete
// ✓ User actually deleted from database
// ✓ Querying for deleted user returns null
// ✓ Related data handled (cascade delete or set null)
// ✓ Returns false/error for non-existent user
// ✓ Cannot delete if user has dependencies
// ✓ Only authorized users can delete
// ✓ Soft delete works (if implemented)

✓ Testing Mutation Variables

Use variables instead of inline values. Cleaner and safer.

// Using variables (recommended)
{
  "query": `
    mutation CreateUser($name: String!, $email: String!) {
      createUser(name: $name, email: $email) {
        id
        name
        email
      }
    }
  `,
  "variables": {
    "name": "Jane Doe",
    "email": "[email protected]"
  }
}

// Benefits:
// ✓ Type checking on variables
// ✓ Prevents injection attacks
// ✓ Reusable queries
// ✓ Easier to test with different values

// Test with edge cases:
"variables": {
  "name": "",                    // Empty name
  "email": "not-an-email"        // Invalid email
}
// Should return validation errors

Testing GraphQL Subscriptions

What Are Subscriptions?

Subscriptions enable real-time updates via WebSocket. Client subscribes to events and receives updates when they occur.

// Client subscribes to new posts
{
  "query": `
    subscription {
      postAdded {
        id
        title
        author {
          name
        }
      }
    }
  `
}

// Server sends update when post is created:
{
  "data": {
    "postAdded": {
      "id": "789",
      "title": "New Post",
      "author": { "name": "John" }
    }
  }
}

✓ Testing Subscription Events

Test that subscriptions receive updates when relevant events occur.

// Example test with Apollo Client
test('Should receive update when post is created', async () => {
  // 1. Subscribe to postAdded
  const subscription = client.subscribe({
    query: gql`
      subscription {
        postAdded {
          id
          title
        }
      }
    `
  });
  
  const updates = [];
  subscription.subscribe({
    next: (data) => updates.push(data)
  });
  
  // 2. Create a post (trigger event)
  await client.mutate({
    mutation: gql`
      mutation {
        createPost(title: "Test Post", content: "...") {
          id
        }
      }
    `
  });
  
  // 3. Wait for subscription update
  await waitFor(() => updates.length > 0);
  
  // 4. Verify received data
  expect(updates[0].data.postAdded.title).toBe("Test Post");
});

// Test Cases:
// ✓ Subscription receives event
// ✓ Data structure matches schema
// ✓ Only subscribed clients receive update
// ✓ Filtered subscriptions work (e.g., userId filter)
// ✓ Connection handles disconnects gracefully
// ✓ Reconnection works after network issue

✓ Testing Subscription Filters

Subscriptions often have filters. Test that clients only receive relevant updates.

// Subscribe to specific user's updates only
{
  "query": `
    subscription {
      userUpdated(userId: "123") {
        id
        name
        email
      }
    }
  `
}

// Test Cases:
// ✓ Receives updates for userId "123"
// ✓ Does NOT receive updates for other users
// ✓ Multiple clients can subscribe to same user
// ✓ Invalid userId handled gracefully
// ✓ Authorization checked (can only subscribe to allowed users)

Testing Error Handling

GraphQL Error Format

GraphQL has standardized error format. Partial success is possible - some data succeeds, some fails.

// Query that partially fails
{
  "query": `
    {
      user(id: "123") { name }
      user(id: "999") { name }  // Doesn't exist
    }
  `
}

// Response with partial success:
{
  "data": {
    "user": {
      "name": "John Doe"
    },
    "user": null  // Failed, see errors
  },
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 3, "column": 5 }],
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND",
        "userId": "999"
      }
    }
  ]
}

// Test Cases:
// ✓ Successful fields return data
// ✓ Failed fields return null
// ✓ errors array contains error details
// ✓ Error includes path to failed field
// ✓ Error extensions provide context

Common GraphQL Errors to Test

Validation Errors

Query doesn't match schema (wrong field names, types, arguments)

Field "invalidField" doesn't exist on type "User"

Not Found Errors

Resource doesn't exist

User with ID "999" not found

Authorization Errors

User doesn't have permission

Not authorized to access this user

Input Validation Errors

Invalid input data (email format, required fields, etc.)

Invalid email format

Server Errors

Database connection failed, unhandled exception

Internal server error

Schema Validation & Testing

✓ Test Schema Changes Don't Break Clients

Schema changes can break client apps. Use schema diff tools to catch breaking changes.

// Safe changes (backward compatible):
✓ Add new type
✓ Add new field to existing type
✓ Add new query/mutation
✓ Add new optional argument
✓ Make required argument optional

// Breaking changes (backward incompatible):
✗ Remove type, field, or query
✗ Rename type or field
✗ Change field type (String → Int)
✗ Add required argument to existing field
✗ Remove argument from field
✗ Make optional argument required

// Use tools to detect:
# GraphQL Inspector
npx graphql-inspector diff old-schema.graphql new-schema.graphql

# Apollo Studio Schema Checks
apollo service:check --variant=production

✓ Validate Schema Follows Best Practices

Use linting tools to ensure schema quality and consistency.

// GraphQL ESLint rules
{
  "rules": {
    "naming-convention": ["error", {
      "types": "PascalCase",
      "fields": "camelCase",
      "enums": "UPPER_CASE"
    }],
    "require-description": "error",
    "no-deprecated": "warn",
    "require-id-when-available": "error"
  }
}

// Check:
// ✓ Consistent naming (PascalCase for types, camelCase for fields)
// ✓ All types have descriptions
// ✓ IDs are properly typed as ID!
// ✓ Deprecated fields have deprecationReason
// ✓ No unused types in schema

GraphQL Testing Tools

Apollo Client Testing Utilities

If you use Apollo Client, use MockedProvider for testing without real server.

import { MockedProvider } from '@apollo/client/testing';

const mocks = [
  {
    request: {
      query: GET_USER,
      variables: { id: '123' }
    },
    result: {
      data: {
        user: { id: '123', name: 'John Doe' }
      }
    }
  }
];

test('Should render user name', async () => {
  render(
    <MockedProvider mocks={mocks} addTypename={false}>
      <UserComponent userId="123" />
    </MockedProvider>
  );
  
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

GraphQL Code Generator

Generate TypeScript types from schema for type-safe testing.

# Install
npm install @graphql-codegen/cli

# codegen.yml
schema: http://localhost:4000/graphql
generates:
  ./types.ts:
    plugins:
      - typescript
      - typescript-operations

# Generate types
npm run codegen

// Now you have type-safe queries:
const query: GetUserQuery = await client.query({
  query: GET_USER,
  variables: { id: '123' }  // TypeScript knows this is ID!
});

GraphQL Playground / GraphiQL

Interactive GraphQL IDE for manual testing. Explore schema, run queries, see docs.

Features:

  • ✓ Auto-completion based on schema
  • ✓ Built-in documentation explorer
  • ✓ Query history
  • ✓ Variables and headers support
  • ✓ Subscription testing

GraphQL Testing Best Practices

✓ DO

  • Test queries with different field selections
  • Test nested queries and relationships
  • Monitor and test for N+1 query problems
  • Test error handling and partial failures
  • Validate schema changes for breaking changes
  • Use variables instead of inline arguments
  • Test authorization at field level

✗ DON'T

  • Only test one field combination
  • Ignore performance testing (N+1 problem)
  • Make breaking schema changes without testing
  • Return raw database errors to clients
  • Skip authorization testing
  • Forget to test subscriptions
  • Expose internal errors via extensions

Related Tools & Resources

External References

Official Documentation & Resources

Conclusion

GraphQL testing requires a different mindset than REST API testing. The flexibility of GraphQL - where clients control data shape - means you need to test more combinations and watch for performance pitfalls like the N+1 query problem. But with the right strategies and tools, you can build confidence that your GraphQL API works correctly.

Start with basic query and mutation tests, then expand to nested queries, subscriptions, and error handling. Use DataLoader to prevent N+1 queries, validate your schema changes for backward compatibility, and leverage tools like Apollo Client testing utilities and GraphQL Code Generator. Remember to test authorization at the field level and monitor performance as your graph grows. A well-tested GraphQL API gives clients the flexibility they want with the reliability they need.