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:
- The
errorsarray — present, structured correctly, right codes - Partial data — non-null fields still resolve when one field errors
- Null propagation — a non-nullable field error bubbles up the tree
- Error extensions — codes, stack traces in dev, metadata
- 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
errorsarray (not 4xx/5xx) errors[].extensions.codeis always set (never just a message string)- Partial responses include both
dataanderrorssimultaneously - 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: nullvs 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.