Testing Strategy for GraphQL
A well-tested GraphQL API requires tests at multiple levels: unit tests for individual resolvers and business logic, integration tests for the full GraphQL execution pipeline, and end-to-end tests for critical user flows. Each level catches different types of bugs and provides different levels of confidence.
Testing Pyramid for GraphQL
- Unit tests: Test resolvers in isolation with mocked data sources
- Integration tests: Execute real GraphQL queries against the schema with a test database
- E2E tests: Test the full HTTP pipeline including auth, middleware, and error handling
Unit Testing Resolvers
// resolvers.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { resolvers } from './resolvers';
import { GraphQLError } from 'graphql';
// Mock Prisma
const mockPrisma = {
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
},
post: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
};
function createMockContext(overrides?: Partial<Context>): Context {
return {
prisma: mockPrisma as any,
currentUser: null,
loaders: createMockLoaders(),
...overrides,
};
}
describe('Query resolvers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('user', () => {
it('returns a user by ID', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const ctx = createMockContext();
const result = await resolvers.Query.user(null, { id: '1' }, ctx);
expect(result).toEqual(mockUser);
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' },
});
});
it('returns null for non-existent user', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
const ctx = createMockContext();
const result = await resolvers.Query.user(null, { id: '999' }, ctx);
expect(result).toBeNull();
});
});
describe('me', () => {
it('returns the current user when authenticated', async () => {
const currentUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
const ctx = createMockContext({ currentUser: currentUser as any });
const result = await resolvers.Query.me(null, {}, ctx);
expect(result).toEqual(currentUser);
});
it('returns null when not authenticated', async () => {
const ctx = createMockContext();
const result = await resolvers.Query.me(null, {}, ctx);
expect(result).toBeNull();
});
});
});
describe('Mutation resolvers', () => {
describe('createPost', () => {
it('creates a post for authenticated user', async () => {
const currentUser = { id: '1', name: 'Alice' };
const newPost = { id: '10', title: 'Test Post', body: 'Content', authorId: '1' };
mockPrisma.post.create.mockResolvedValue(newPost);
const ctx = createMockContext({ currentUser: currentUser as any });
const result = await resolvers.Mutation.createPost(
null,
{ input: { title: 'Test Post', body: 'Content' } },
ctx
);
expect(result).toEqual(newPost);
expect(mockPrisma.post.create).toHaveBeenCalledWith({
data: expect.objectContaining({
title: 'Test Post',
authorId: '1',
}),
});
});
it('throws UNAUTHENTICATED for anonymous users', async () => {
const ctx = createMockContext();
await expect(
resolvers.Mutation.createPost(null, { input: { title: 'Test', body: 'Content' } }, ctx)
).rejects.toThrow(GraphQLError);
});
});
describe('deletePost', () => {
it('throws FORBIDDEN when user does not own the post', async () => {
const currentUser = { id: '1', name: 'Alice' };
const post = { id: '10', authorId: '2' }; // Different author
mockPrisma.post.findUnique.mockResolvedValue(post);
const ctx = createMockContext({ currentUser: currentUser as any });
await expect(
resolvers.Mutation.deletePost(null, { id: '10' }, ctx)
).rejects.toThrow('unauthorized');
});
});
});
Integration Testing with Real Schema Execution
// integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ApolloServer } from '@apollo/server';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
let server: ApolloServer;
beforeAll(async () => {
server = new ApolloServer({ typeDefs, resolvers });
await server.start();
// Seed test data
await prisma.user.create({
data: { id: 'test-user-1', name: 'Test User', email: 'test@test.com', password: 'hashed' },
});
await prisma.post.create({
data: { id: 'test-post-1', title: 'Test Post', body: 'Content', authorId: 'test-user-1' },
});
});
afterAll(async () => {
await prisma.post.deleteMany();
await prisma.user.deleteMany();
await prisma.$disconnect();
await server.stop();
});
describe('GraphQL Integration Tests', () => {
it('fetches a user with their posts', async () => {
const response = await server.executeOperation({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
posts {
id
title
}
}
}
`,
variables: { id: 'test-user-1' },
}, {
contextValue: {
prisma,
currentUser: null,
loaders: createLoaders(prisma),
},
});
expect(response.body.kind).toBe('single');
if (response.body.kind === 'single') {
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.user).toEqual({
id: 'test-user-1',
name: 'Test User',
posts: [{ id: 'test-post-1', title: 'Test Post' }],
});
}
});
it('requires authentication for createPost', async () => {
const response = await server.executeOperation({
query: `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) { id title }
}
`,
variables: { input: { title: 'New Post', body: 'Content' } },
}, {
contextValue: { prisma, currentUser: null, loaders: createLoaders(prisma) },
});
if (response.body.kind === 'single') {
expect(response.body.singleResult.errors).toBeDefined();
expect(response.body.singleResult.errors![0].extensions?.code).toBe('UNAUTHENTICATED');
}
});
});
Testing with Supertest (E2E)
// e2e.test.ts
import request from 'supertest';
import { app } from './app'; // Your Express app
describe('GraphQL E2E Tests', () => {
it('returns 200 for valid queries', async () => {
const response = await request(app)
.post('/graphql')
.send({
query: '{ users(limit: 5) { id name } }',
})
.expect(200);
expect(response.body.data.users).toBeDefined();
expect(response.body.errors).toBeUndefined();
});
it('handles authentication flow', async () => {
// Sign up
const signUpRes = await request(app)
.post('/graphql')
.send({
query: `
mutation {
signUp(input: { name: "E2E User", email: "e2e@test.com", password: "securepass123" }) {
token
user { id name }
}
}
`,
})
.expect(200);
const token = signUpRes.body.data.signUp.token;
// Use token for authenticated request
const meRes = await request(app)
.post('/graphql')
.set('Authorization', `Bearer ${token}`)
.send({ query: '{ me { id name email } }' })
.expect(200);
expect(meRes.body.data.me.name).toBe('E2E User');
});
});
Testing Best Practices
- Test the schema contract: Verify queries return the expected shape
- Test error cases: Verify auth failures, validation errors, and not-found cases
- Use a test database: Use a separate database for integration tests to avoid data contamination
- Mock DataLoaders: In unit tests, mock DataLoaders to test resolvers in isolation
- Test N+1 prevention: Log database queries during tests to verify batching works