Testing GraphQL Error Handling: Partial Responses, Null Propagation & Error Extensions

Testing GraphQL Error Handling: Partial Responses, Null Propagation & Error Extensions

GraphQL error handling is fundamentally different from REST. A 200 response can contain errors. A query can partially succeed — returning some data while failing on other fields. Null propagation can silently wipe out entire subtrees. If you're only testing the happy path, you're missing half the behavior.

This guide covers testing GraphQL errors systematically, from unit tests of resolver error logic to E2E tests of client error boundaries.

GraphQL Error Anatomy

A GraphQL response with errors looks like:

{
  "data": {
    "user": {
      "name": "Alice",
      "orders": null
    }
  },
  "errors": [
    {
      "message": "Not authorized to view orders",
      "locations": [{ "line": 3, "column": 5 }],
      "path": ["user", "orders"],
      "extensions": {
        "code": "UNAUTHORIZED",
        "serviceName": "OrderService"
      }
    }
  ]
}

Key things to test:

  1. The errors array — present, structured correctly, right codes
  2. Partial data — non-null fields still resolve when one field errors
  3. Null propagation — a non-nullable field error bubbles up the tree
  4. Error extensions — codes, stack traces in dev, metadata
  5. Multiple errors — batch failures return all errors, not just the first

Unit Testing Resolver Error Handling

Test: Resolver Throws AuthenticationError

// resolvers/user.test.js
import { UserResolvers } from './user';
import { createMockContext } from '../test-utils';

describe('User.orders resolver', () => {
  it('should throw UNAUTHORIZED when user is not authenticated', async () => {
    const context = createMockContext({ userId: null }); // Not authenticated
    const parent = { id: 'user-1' };

    await expect(
      UserResolvers.orders(parent, {}, context)
    ).rejects.toMatchObject({
      message: 'Not authorized to view orders',
      extensions: {
        code: 'UNAUTHORIZED',
      },
    });
  });

  it('should return orders for authenticated user', async () => {
    const context = createMockContext({ userId: 'user-1' });
    const parent = { id: 'user-1' };

    const orders = await UserResolvers.orders(parent, {}, context);

    expect(orders).toBeInstanceOf(Array);
    expect(orders.length).toBeGreaterThan(0);
  });

  it('should throw NOT_FOUND for non-existent user', async () => {
    const context = createMockContext({ userId: 'admin' });
    const parent = { id: 'nonexistent-id' };

    await expect(
      UserResolvers.orders(parent, {}, context)
    ).rejects.toMatchObject({
      extensions: { code: 'NOT_FOUND' },
    });
  });
});

Test: Custom Error Classes

// errors.test.js
import { GraphQLError } from 'graphql';
import { NotFoundError, ValidationError, RateLimitError } from './errors';

describe('Custom error classes', () => {
  it('NotFoundError has correct extension code', () => {
    const err = new NotFoundError('User not found', 'user-123');
    expect(err).toBeInstanceOf(GraphQLError);
    expect(err.extensions.code).toBe('NOT_FOUND');
    expect(err.extensions.id).toBe('user-123');
    expect(err.message).toBe('User not found');
  });

  it('RateLimitError includes retry-after', () => {
    const err = new RateLimitError(30);
    expect(err.extensions.code).toBe('RATE_LIMITED');
    expect(err.extensions.retryAfterSeconds).toBe(30);
  });

  it('ValidationError includes field details', () => {
    const err = new ValidationError('email', 'Invalid email format');
    expect(err.extensions.code).toBe('VALIDATION_FAILED');
    expect(err.extensions.field).toBe('email');
  });
});

Integration Testing Partial Responses

Send a real query and verify the partial data + errors structure:

// integration/partial-response.test.js
import request from 'supertest';
import { app } from '../app';

describe('Partial response behavior', () => {
  it('should return partial data when one field errors', async () => {
    const query = `
      query GetUserWithOrders($id: ID!) {
        user(id: $id) {
          name
          email
          orders {
            id
            total
          }
        }
      }
    `;

    // Authenticate as user-2 trying to get user-1's data
    const res = await request(app)
      .post('/graphql')
      .set('Authorization', 'Bearer user-2-token')
      .send({ query, variables: { id: 'user-1' } });

    expect(res.status).toBe(200); // GraphQL always returns 200
    
    // Should have partial data
    expect(res.body.data).toBeDefined();
    expect(res.body.data.user.name).toBeDefined();
    expect(res.body.data.user.email).toBeDefined();
    expect(res.body.data.user.orders).toBeNull(); // Failed field is null

    // Should have errors array
    expect(res.body.errors).toBeDefined();
    expect(res.body.errors).toHaveLength(1);
    expect(res.body.errors[0].path).toEqual(['user', 'orders']);
    expect(res.body.errors[0].extensions.code).toBe('UNAUTHORIZED');
  });

  it('should return all errors in a multi-field query', async () => {
    const query = `
      query {
        user(id: "missing") { name }
        product(id: "also-missing") { title }
        category(id: "gone") { name }
      }
    `;

    const res = await request(app)
      .post('/graphql')
      .send({ query });

    expect(res.status).toBe(200);
    expect(res.body.errors).toHaveLength(3);

    // Each error should have the correct path
    const paths = res.body.errors.map(e => e.path[0]);
    expect(paths).toContain('user');
    expect(paths).toContain('product');
    expect(paths).toContain('category');
  });
});

Testing Null Propagation

GraphQL null propagation is counterintuitive: if a non-nullable field errors, the null "bubbles up" to the nearest nullable parent — potentially wiping out fields that resolved successfully.

type User {
  id: ID!         # Non-nullable
  name: String!   # Non-nullable
  email: String   # Nullable — null stays here if it errors
}

type Query {
  user(id: ID!): User  # Nullable — null propagates here from User.id or User.name
}
describe('Null propagation', () => {
  it('should null out entire user object when non-nullable field errors', async () => {
    // Simulate a case where user.name throws (non-nullable String!)
    const query = `query { user(id: "corrupt-user") { id name email } }`;

    const res = await request(app)
      .post('/graphql')
      .send({ query });

    // user.name is String! and errored, so it can't be null
    // GraphQL propagates the null up to user (which IS nullable)
    expect(res.body.data.user).toBeNull();
    expect(res.body.errors).toBeDefined();
    expect(res.body.errors[0].path).toEqual(['user', 'name']);
  });

  it('should contain null in data even with partial errors', async () => {
    // When partial response contains errors, data is NOT null overall
    const res = await request(app)
      .post('/graphql')
      .send({ query: '{ user(id: "1") { name } brokenField }' });

    // data should exist but brokenField should be null
    expect(res.body.data).not.toBeNull();
    expect(res.body.data.brokenField).toBeNull();
    expect(res.body.errors).toBeDefined();
  });
});

Testing Error Extensions

Extensions carry metadata like error codes, trace IDs, and documentation links. Verify they're structured correctly:

describe('Error extensions', () => {
  it('should include error code in extensions', async () => {
    const res = await request(app)
      .post('/graphql')
      .send({ query: '{ adminDashboard }' }); // Requires admin role

    expect(res.body.errors[0].extensions).toMatchObject({
      code: 'FORBIDDEN',
    });
  });

  it('should NOT include stack trace in production', async () => {
    process.env.NODE_ENV = 'production';

    const res = await request(app)
      .post('/graphql')
      .send({ query: '{ triggerServerError }' });

    const ext = res.body.errors[0].extensions;
    expect(ext.stacktrace).toBeUndefined();
    expect(ext.exception).toBeUndefined();

    process.env.NODE_ENV = 'test';
  });

  it('should include trace ID for correlation', async () => {
    const res = await request(app)
      .post('/graphql')
      .set('X-Request-Id', 'trace-abc-123')
      .send({ query: '{ triggerServerError }' });

    expect(res.body.errors[0].extensions.requestId).toBe('trace-abc-123');
  });
});

E2E Testing Client Error Handling

Test that your client-side code handles GraphQL errors correctly:

React Apollo Error Boundary Test

// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { GraphQLError } from 'graphql';
import UserProfile from './UserProfile';
import { GET_USER } from './queries';

const unauthorizedMock = {
  request: { query: GET_USER, variables: { id: '1' } },
  result: {
    data: { user: { name: 'Alice', orders: null } },
    errors: [new GraphQLError('Not authorized', {
      path: ['user', 'orders'],
      extensions: { code: 'UNAUTHORIZED' },
    })],
  },
};

const networkErrorMock = {
  request: { query: GET_USER, variables: { id: '2' } },
  error: new Error('Network request failed'),
};

describe('UserProfile error handling', () => {
  it('shows partial data with error message when orders unauthorized', async () => {
    render(
      <MockedProvider mocks={[unauthorizedMock]}>
        <UserProfile userId="1" />
      </MockedProvider>
    );

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });

    // Should show user name but indicate orders unavailable
    expect(screen.getByText(/orders not available/i)).toBeInTheDocument();
    expect(screen.queryByText(/error loading/i)).not.toBeInTheDocument();
  });

  it('shows error state on network failure', async () => {
    render(
      <MockedProvider mocks={[networkErrorMock]}>
        <UserProfile userId="2" />
      </MockedProvider>
    );

    await waitFor(() => {
      expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
    });
  });

  it('shows retry button on network error', async () => {
    const { getByRole } = render(
      <MockedProvider mocks={[networkErrorMock]}>
        <UserProfile userId="2" />
      </MockedProvider>
    );

    await waitFor(() => {
      expect(getByRole('button', { name: /retry/i })).toBeInTheDocument();
    });
  });
});

Playwright E2E Error Boundary Test

// tests/error-handling.spec.js
import { test, expect } from '@playwright/test';

test.describe('GraphQL error handling in UI', () => {
  test('shows partial content when some fields fail', async ({ page }) => {
    // Intercept the GraphQL request and inject an error
    await page.route('**/graphql', async (route) => {
      const body = await route.request().postDataJSON();
      
      if (body.operationName === 'GetUserProfile') {
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({
            data: {
              user: {
                __typename: 'User',
                id: '1',
                name: 'Alice',
                email: 'alice@example.com',
                orders: null,
              },
            },
            errors: [{
              message: 'Not authorized to view orders',
              path: ['user', 'orders'],
              extensions: { code: 'UNAUTHORIZED' },
            }],
          }),
        });
      } else {
        await route.continue();
      }
    });

    await page.goto('/users/1');

    // User info should be visible
    await expect(page.getByText('Alice')).toBeVisible();
    await expect(page.getByText('alice@example.com')).toBeVisible();

    // Orders section should show permission error, not crash
    await expect(page.getByText(/orders not available/i)).toBeVisible();
    
    // No error boundary triggered
    await expect(page.getByText(/something went wrong/i)).not.toBeVisible();
  });

  test('gracefully handles complete query failure', async ({ page }) => {
    await page.route('**/graphql', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          data: null,
          errors: [{
            message: 'Internal server error',
            extensions: { code: 'INTERNAL_SERVER_ERROR' },
          }],
        }),
      });
    });

    await page.goto('/users/1');

    await expect(page.getByRole('heading', { name: /error/i })).toBeVisible();
    await expect(page.getByRole('button', { name: /try again/i })).toBeVisible();
  });
});

Testing Error Rate Limiting

Ensure your API doesn't leak information by throttling error responses:

describe('Error rate limiting', () => {
  it('should not reveal user existence via error message', async () => {
    // Try to get a non-existent user
    const res1 = await request(app)
      .post('/graphql')
      .send({ query: '{ user(id: "nonexistent") { name } }' });

    // Try to get an existing but unauthorized user
    const res2 = await request(app)
      .post('/graphql')
      .send({ query: '{ user(id: "private-user") { name } }' });

    // Both should return the same generic error message
    // (Don't reveal whether user exists vs. unauthorized)
    const msg1 = res1.body.errors?.[0]?.message;
    const msg2 = res2.body.errors?.[0]?.message;
    
    // Depending on your security model:
    // Option 1: Both return "Not found"
    // Option 2: Both return "Unauthorized"
    // The key is they should be the SAME to prevent enumeration
    expect(msg1).toBe(msg2);
  });
});

Error Handling Checklist

  • All error responses return HTTP 200 with errors array (not 4xx/5xx)
  • errors[].extensions.code is always set (never just a message string)
  • Partial responses include both data and errors simultaneously
  • Non-nullable field errors propagate null correctly up the tree
  • Stack traces are omitted in production environments
  • Request/trace IDs included in errors for correlation
  • Client UI handles data: null vs partial data vs no errors
  • Error boundaries catch complete query failures
  • User-facing error messages don't leak internal details
  • Multiple errors in one response are all returned (no early termination)

GraphQL's flexible error model is a feature, but it requires explicit testing. The partial response behavior alone is enough to trip up both server implementations and client error handling logic in subtle ways that only appear at the edges.

Read more