Testing GraphQL APIs with Jest and Apollo Server
Apollo Server is the most popular GraphQL server for Node.js. Its testing story is surprisingly good: executeOperation lets you run queries against an in-memory server without HTTP overhead, making integration tests fast and deterministic.
This guide focuses on the practical patterns you need for testing Apollo Server 4 applications with Jest.
Setup
Install dependencies:
npm install @apollo/server graphql
npm install --save-dev jest @jest/globalsFor TypeScript:
npm install --save-dev @types/jest ts-jestBasic Apollo Server Test Setup
A minimal test helper that creates a server and executes operations:
// tests/helpers/testServer.ts
import { ApolloServer, BaseContext } from '@apollo/server';
import { DocumentNode } from 'graphql';
export interface TestContext extends BaseContext {
user: { id: string; role: string } | null;
db: Database;
}
export async function createTestServer() {
const server = new ApolloServer<TestContext>({
typeDefs,
resolvers,
// Disable error masking in tests to see full errors
includeStacktraceInErrorResponses: true,
});
await server.start();
return server;
}
export async function gql(
server: ApolloServer<TestContext>,
query: string | DocumentNode,
{
variables = {},
user = null,
db = testDb,
}: {
variables?: Record<string, unknown>;
user?: TestContext['user'];
db?: Database;
} = {}
) {
const result = await server.executeOperation(
{ query: typeof query === 'string' ? query : print(query), variables },
{ contextValue: { user, db } }
);
if (result.body.kind !== 'single') throw new Error('Unexpected incremental result');
return result.body.singleResult;
}Your First Tests
// tests/resolvers/query.test.ts
import { createTestServer, gql } from '../helpers/testServer';
import { testDb } from '../helpers/testDb';
import type { ApolloServer } from '@apollo/server';
describe('Query resolvers', () => {
let server: ApolloServer;
beforeAll(async () => {
server = await createTestServer();
await testDb.migrate();
});
afterAll(async () => {
await server.stop();
await testDb.close();
});
beforeEach(() => testDb.seed());
afterEach(() => testDb.cleanup());
describe('Query.me', () => {
it('returns the authenticated user', async () => {
const result = await gql(
server,
`query { me { id email role } }`,
{ user: { id: '1', role: 'user' } }
);
expect(result.errors).toBeUndefined();
expect(result.data?.me).toEqual({
id: '1',
email: 'alice@example.com',
role: 'user',
});
});
it('returns null when not authenticated', async () => {
const result = await gql(server, `query { me { id } }`, { user: null });
expect(result.errors).toBeUndefined();
expect(result.data?.me).toBeNull();
});
});
describe('Query.products', () => {
it('returns paginated products', async () => {
await testDb.products.bulkCreate(20);
const result = await gql(
server,
`query Products($first: Int!, $after: String) {
products(first: $first, after: $after) {
edges {
node { id name price }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}`,
{ variables: { first: 10 }, user: { id: '1', role: 'user' } }
);
expect(result.errors).toBeUndefined();
const { edges, pageInfo } = result.data?.products;
expect(edges).toHaveLength(10);
expect(pageInfo.hasNextPage).toBe(true);
expect(pageInfo.endCursor).toBeDefined();
});
it('returns empty list when no products', async () => {
const result = await gql(
server,
`query { products(first: 10) { edges { node { id } } } }`,
{ user: { id: '1', role: 'user' } }
);
expect(result.data?.products.edges).toHaveLength(0);
});
});
});Testing Mutations
describe('Mutation.createProduct', () => {
const CREATE_PRODUCT = `
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
id
name
price
slug
}
}
`;
it('creates product and returns it', async () => {
const result = await gql(
server,
CREATE_PRODUCT,
{
variables: {
input: { name: 'Test Widget', price: 9.99, description: 'A widget' },
},
user: { id: '1', role: 'admin' },
}
);
expect(result.errors).toBeUndefined();
const product = result.data?.createProduct;
expect(product.id).toBeDefined();
expect(product.name).toBe('Test Widget');
expect(product.price).toBe(9.99);
expect(product.slug).toBe('test-widget');
});
it('requires admin role', async () => {
const result = await gql(
server,
CREATE_PRODUCT,
{
variables: { input: { name: 'Widget', price: 9.99 } },
user: { id: '1', role: 'user' }, // non-admin
}
);
expect(result.errors).toContainEqual(
expect.objectContaining({
extensions: { code: 'FORBIDDEN' },
path: ['createProduct'],
})
);
expect(result.data?.createProduct).toBeNull();
});
it('validates required fields', async () => {
const result = await gql(
server,
CREATE_PRODUCT,
{
variables: { input: { price: 9.99 } }, // missing name
user: { id: '1', role: 'admin' },
}
);
expect(result.errors).toContainEqual(
expect.objectContaining({
extensions: { code: 'BAD_USER_INPUT' },
})
);
});
it('returns CONFLICT on duplicate slug', async () => {
await testDb.products.create({ name: 'Test Widget', slug: 'test-widget' });
const result = await gql(
server,
CREATE_PRODUCT,
{
variables: { input: { name: 'Test Widget', price: 9.99 } },
user: { id: '1', role: 'admin' },
}
);
expect(result.errors).toContainEqual(
expect.objectContaining({
extensions: { code: 'CONFLICT' },
})
);
});
});Mocking Context
The context is the primary extension point in Apollo Server. Test different context states to cover authentication and authorization paths:
// Test different user states
const adminUser = { id: '1', role: 'admin' };
const regularUser = { id: '2', role: 'user' };
const unauthenticatedUser = null;
// Admin can access everything
it('admin accesses admin-only query', async () => {
const result = await gql(server, `query { adminDashboard { userCount } }`, { user: adminUser });
expect(result.errors).toBeUndefined();
});
// Regular user gets FORBIDDEN
it('regular user cannot access admin query', async () => {
const result = await gql(server, `query { adminDashboard { userCount } }`, { user: regularUser });
expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
// Unauthenticated gets UNAUTHENTICATED
it('unauthenticated user gets UNAUTHENTICATED error', async () => {
const result = await gql(server, `query { adminDashboard { userCount } }`, { user: null });
expect(result.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED');
});Mocking External Services
When resolvers call external APIs or services, mock them in tests:
// Mock the payment service
jest.mock('../services/payments', () => ({
PaymentService: jest.fn().mockImplementation(() => ({
charge: jest.fn().mockResolvedValue({
id: 'ch_test_123',
status: 'succeeded',
amount: 9999,
}),
refund: jest.fn().mockResolvedValue({ id: 'rf_test_123' }),
})),
}));
import { PaymentService } from '../services/payments';
describe('Mutation.purchaseProduct', () => {
let mockPaymentService: jest.Mocked<InstanceType<typeof PaymentService>>;
beforeEach(() => {
mockPaymentService = new PaymentService() as jest.Mocked<InstanceType<typeof PaymentService>>;
});
it('charges the correct amount', async () => {
const result = await gql(
server,
`mutation { purchaseProduct(productId: "1", paymentMethodId: "pm_test") { orderId } }`,
{ user: { id: '2', role: 'user' } }
);
expect(mockPaymentService.charge).toHaveBeenCalledWith(
expect.objectContaining({ amount: 999 }) // $9.99 in cents
);
});
it('handles payment failure gracefully', async () => {
mockPaymentService.charge.mockRejectedValueOnce(
new Error('Card declined')
);
const result = await gql(
server,
`mutation { purchaseProduct(productId: "1", paymentMethodId: "pm_fail") { orderId } }`,
{ user: { id: '2', role: 'user' } }
);
expect(result.errors).toContainEqual(
expect.objectContaining({
extensions: { code: 'PAYMENT_FAILED' },
})
);
});
});Testing DataLoader
DataLoader is the solution to N+1 queries. Test that it batches correctly:
// resolvers/Product.ts
import DataLoader from 'dataloader';
export const createCategoryLoader = (db: Database) =>
new DataLoader<string, Category>(async (categoryIds) => {
const categories = await db.categories.findByIds([...categoryIds]);
const categoryMap = new Map(categories.map(c => [c.id, c]));
return categoryIds.map(id => categoryMap.get(id) ?? new Error(`Category ${id} not found`));
});// tests/resolvers/dataloader.test.ts
import DataLoader from 'dataloader';
import { createCategoryLoader } from '../../resolvers/Product';
describe('createCategoryLoader', () => {
it('batches multiple category lookups into one query', async () => {
const mockDb = {
categories: {
findByIds: jest.fn().mockResolvedValue([
{ id: '1', name: 'Electronics' },
{ id: '2', name: 'Books' },
]),
},
};
const loader = createCategoryLoader(mockDb as any);
// Load two categories concurrently
const [cat1, cat2] = await Promise.all([
loader.load('1'),
loader.load('2'),
]);
expect(cat1).toEqual({ id: '1', name: 'Electronics' });
expect(cat2).toEqual({ id: '2', name: 'Books' });
// Should have been called ONCE with both IDs (batched)
expect(mockDb.categories.findByIds).toHaveBeenCalledTimes(1);
expect(mockDb.categories.findByIds).toHaveBeenCalledWith(['1', '2']);
});
it('returns error for non-existent category', async () => {
const mockDb = {
categories: { findByIds: jest.fn().mockResolvedValue([]) },
};
const loader = createCategoryLoader(mockDb as any);
await expect(loader.load('999')).rejects.toThrow('Category 999 not found');
});
});Testing Directives
Custom directives often implement auth rules. Test them explicitly:
// Schema: type User @requiresRole(role: "admin") { ... }
describe('@requiresRole directive', () => {
it('allows access for users with required role', async () => {
const result = await gql(
server,
`query { adminOnlyField }`,
{ user: { id: '1', role: 'admin' } }
);
expect(result.errors).toBeUndefined();
});
it('denies access for users without required role', async () => {
const result = await gql(
server,
`query { adminOnlyField }`,
{ user: { id: '2', role: 'user' } }
);
expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});Snapshot Testing for Schema
Snapshot the full SDL to catch unintended schema changes:
import { lexicographicSortSchema, printSchema } from 'graphql';
import { makeExecutableSchema } from '@graphql-tools/schema';
it('schema has not changed unexpectedly', () => {
const schema = makeExecutableSchema({ typeDefs, resolvers });
const schemaString = printSchema(lexicographicSortSchema(schema));
expect(schemaString).toMatchSnapshot();
});When you intentionally change the schema, update the snapshot with jest --updateSnapshot. This prevents accidental breaking changes from slipping through.
Organizing Your Test Suite
For a medium-sized GraphQL API, structure tests like this:
tests/
helpers/
testServer.ts
testDb.ts
factories.ts # test data factories
resolvers/
query/
user.test.ts
products.test.ts
orders.test.ts
mutation/
createProduct.test.ts
updateOrder.test.ts
auth.test.ts
subscription/
orderUpdated.test.ts
directives/
requiresRole.test.ts
rateLimit.test.ts
schema/
schema.test.ts # validation + snapshotThis organization mirrors the resolver file structure, making it easy to find tests for any resolver.