Testing NestJS GraphQL Resolvers, Subscriptions, and Schema Validation
Testing GraphQL APIs in NestJS requires a different approach than REST. The entry point is always POST /graphql, the response is always 200 OK (even for errors), and you need to think about resolvers, subscriptions, schema validation, and the N+1 problem. This guide covers all of it.
Testing Resolvers in Isolation
A NestJS GraphQL resolver is a class decorated with @Resolver(). Its methods are plain TypeScript — no HTTP context. Unit testing is straightforward:
// users/users.resolver.ts
import { Resolver, Query, Mutation, Args, ID } from '@nestjs/graphql';
import { UsersService } from './users.service';
import { User } from './models/user.model';
import { CreateUserInput } from './dto/create-user.input';
import { UpdateUserInput } from './dto/update-user.input';
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => User, { name: 'user' })
findOne(@Args('id', { type: () => ID }) id: string) {
return this.usersService.findOne(id);
}
@Query(() => [User], { name: 'users' })
findAll() {
return this.usersService.findAll();
}
@Mutation(() => User)
createUser(@Args('createUserInput') createUserInput: CreateUserInput) {
return this.usersService.create(createUserInput);
}
@Mutation(() => User)
updateUser(@Args('updateUserInput') updateUserInput: UpdateUserInput) {
return this.usersService.update(updateUserInput.id, updateUserInput);
}
@Mutation(() => Boolean)
async removeUser(@Args('id', { type: () => ID }) id: string) {
await this.usersService.remove(id);
return true;
}
}Unit tests call the resolver methods directly:
// users/users.resolver.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersResolver } from './users.resolver';
import { UsersService } from './users.service';
describe('UsersResolver', () => {
let resolver: UsersResolver;
let mockUsersService: {
findOne: jest.Mock;
findAll: jest.Mock;
create: jest.Mock;
update: jest.Mock;
remove: jest.Mock;
};
beforeEach(async () => {
mockUsersService = {
findOne: jest.fn(),
findAll: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersResolver,
{
provide: UsersService,
useValue: mockUsersService,
},
],
}).compile();
resolver = module.get<UsersResolver>(UsersResolver);
});
describe('findOne', () => {
it('returns a user by id', async () => {
const user = { id: '1', email: 'test@example.com', name: 'Test' };
mockUsersService.findOne.mockResolvedValue(user);
const result = await resolver.findOne('1');
expect(result).toEqual(user);
expect(mockUsersService.findOne).toHaveBeenCalledWith('1');
});
it('propagates not found errors', async () => {
mockUsersService.findOne.mockRejectedValue(new Error('User not found'));
await expect(resolver.findOne('999')).rejects.toThrow('User not found');
});
});
describe('createUser', () => {
it('creates user with provided input', async () => {
const input = { email: 'new@example.com', name: 'New User' };
const created = { id: '2', ...input };
mockUsersService.create.mockResolvedValue(created);
const result = await resolver.createUser(input as any);
expect(result).toEqual(created);
expect(mockUsersService.create).toHaveBeenCalledWith(input);
});
});
describe('removeUser', () => {
it('removes user and returns true', async () => {
mockUsersService.remove.mockResolvedValue(undefined);
const result = await resolver.removeUser('1');
expect(result).toBe(true);
expect(mockUsersService.remove).toHaveBeenCalledWith('1');
});
});
});E2E Testing GraphQL Endpoints
Unit tests cover resolver logic. E2E tests verify the full GraphQL pipeline — query parsing, schema validation, resolver execution, response serialization:
// test/graphql.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('GraphQL (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
function gql(query: string, variables?: Record<string, any>) {
return request(app.getHttpServer())
.post('/graphql')
.send({ query, variables });
}
describe('users query', () => {
it('returns list of users', async () => {
const response = await gql(`
query {
users {
id
email
name
}
}
`).expect(200);
expect(response.body.errors).toBeUndefined();
expect(Array.isArray(response.body.data.users)).toBe(true);
});
it('returns user by id', async () => {
const response = await gql(`
query GetUser($id: ID!) {
user(id: $id) {
id
email
name
}
}
`, { id: '1' }).expect(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.user).toMatchObject({
id: '1',
email: expect.any(String),
});
});
it('returns GraphQL error for non-existent user', async () => {
const response = await gql(`
query {
user(id: "99999") {
id
}
}
`).expect(200); // GraphQL always returns 200
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
});
});The helper gql() function removes boilerplate. Notice that GraphQL always returns HTTP 200 — errors appear in response.body.errors, not in HTTP status codes. Every e2e test must explicitly check response.body.errors.
Testing Mutations with Authentication
Protected mutations require a Bearer token in the request:
describe('Authenticated mutations', () => {
let authToken: string;
beforeAll(async () => {
const loginResponse = await gql(`
mutation {
login(email: "admin@example.com", password: "password") {
access_token
}
}
`);
authToken = loginResponse.body.data.login.access_token;
});
function authGql(query: string, variables?: Record<string, any>) {
return request(app.getHttpServer())
.post('/graphql')
.set('Authorization', `Bearer ${authToken}`)
.send({ query, variables });
}
it('creates a user when authenticated', async () => {
const response = await authGql(`
mutation CreateUser($input: CreateUserInput!) {
createUser(createUserInput: $input) {
id
email
name
}
}
`, {
input: {
email: 'created@example.com',
name: 'Created User',
password: 'secure123',
}
}).expect(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.createUser).toMatchObject({
id: expect.any(String),
email: 'created@example.com',
});
});
it('returns authorization error without token', async () => {
const response = await gql(`
mutation {
createUser(createUserInput: { email: "x@y.com", name: "X", password: "pass" }) {
id
}
}
`).expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
});
});Testing Field Resolvers and DataLoaders
Field resolvers are common sources of the N+1 problem. Test them both in isolation and verify DataLoader batching works:
// posts/posts.resolver.ts
import { Resolver, ResolveField, Parent } from '@nestjs/graphql';
import { Post } from './models/post.model';
import { User } from '../users/models/user.model';
import { UsersService } from '../users/users.service';
@Resolver(() => Post)
export class PostsResolver {
constructor(private readonly usersService: UsersService) {}
@ResolveField('author', () => User)
async getAuthor(@Parent() post: Post): Promise<User> {
return this.usersService.findOne(post.authorId);
}
}Unit test the field resolver directly:
describe('PostsResolver field resolvers', () => {
let resolver: PostsResolver;
let mockUsersService: { findOne: jest.Mock };
beforeEach(async () => {
mockUsersService = { findOne: jest.fn() };
const module = await Test.createTestingModule({
providers: [
PostsResolver,
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();
resolver = module.get<PostsResolver>(PostsResolver);
});
it('resolves author from post.authorId', async () => {
const post = { id: '1', title: 'Test', authorId: 'user-5' } as any;
const author = { id: 'user-5', name: 'Author' };
mockUsersService.findOne.mockResolvedValue(author);
const result = await resolver.getAuthor(post);
expect(result).toEqual(author);
expect(mockUsersService.findOne).toHaveBeenCalledWith('user-5');
});
});For DataLoader testing, verify that multiple calls in the same request are batched:
// users/users.dataloader.spec.ts
import { UsersDataLoader } from './users.dataloader';
describe('UsersDataLoader', () => {
let mockUsersService: { findByIds: jest.Mock };
let loader: UsersDataLoader;
beforeEach(() => {
mockUsersService = { findByIds: jest.fn() };
loader = new UsersDataLoader(mockUsersService as any);
});
it('batches multiple load calls into one DB query', async () => {
const users = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Charlie' },
];
mockUsersService.findByIds.mockResolvedValue(users);
// Simulate concurrent loads (as would happen in a single request)
const [user1, user2, user3] = await Promise.all([
loader.load('1'),
loader.load('2'),
loader.load('3'),
]);
expect(mockUsersService.findByIds).toHaveBeenCalledTimes(1);
expect(mockUsersService.findByIds).toHaveBeenCalledWith(['1', '2', '3']);
expect(user1).toEqual(users[0]);
expect(user2).toEqual(users[1]);
expect(user3).toEqual(users[2]);
});
});Testing Schema Validation
Test that your schema rejects invalid inputs before they reach resolvers:
describe('Schema input validation', () => {
it('rejects invalid email in createUser mutation', async () => {
const response = await gql(`
mutation {
createUser(createUserInput: {
email: "not-an-email",
name: "Test",
password: "pass123"
}) {
id
}
}
`).expect(200);
expect(response.body.errors).toBeDefined();
// Validation errors appear differently depending on whether you use
// class-validator or scalar types for validation
expect(response.body.errors[0].message).toMatch(/email/i);
});
it('rejects missing required fields', async () => {
const response = await gql(`
mutation {
createUser(createUserInput: {
name: "Test"
}) {
id
}
}
`).expect(400); // Missing required args return 400 at parse time
// This is caught by GraphQL schema validation before hitting resolvers
expect(response.body.errors).toBeDefined();
});
});Testing GraphQL Subscriptions
Subscriptions use WebSockets. Test them with the graphql-ws client:
// test/subscriptions.e2e-spec.ts
import { createClient } from 'graphql-ws';
import * as WebSocket from 'ws';
describe('GraphQL Subscriptions (e2e)', () => {
let app: INestApplication;
let wsClient: ReturnType<typeof createClient>;
beforeAll(async () => {
// ... app setup
wsClient = createClient({
url: `ws://localhost:${app.getHttpServer().address().port}/graphql`,
webSocketImpl: WebSocket,
});
});
afterAll(async () => {
await new Promise<void>((resolve) => wsClient.dispose().then(resolve));
await app.close();
});
it('receives subscription events when order is created', async () => {
const received: any[] = [];
await new Promise<void>((resolve, reject) => {
const unsubscribe = wsClient.subscribe(
{
query: `
subscription {
orderCreated {
id
status
userId
}
}
`,
},
{
next: (data) => {
received.push(data);
if (received.length >= 1) {
unsubscribe();
resolve();
}
},
error: reject,
complete: resolve,
}
);
// Trigger the event after subscribing
setTimeout(async () => {
await gql(`
mutation {
createOrder(input: { userId: "user-1", items: [] }) {
id
}
}
`);
}, 100);
});
expect(received).toHaveLength(1);
expect(received[0].data.orderCreated).toMatchObject({
id: expect.any(String),
status: 'pending',
});
}, 10000);
});Subscription tests are inherently async and timing-dependent. Use explicit timeouts and resolve-on-receive patterns rather than arbitrary setTimeout delays.
Testing Custom GraphQL Scalars
Custom scalars (like DateTime, JSON, EmailAddress) need validation tests:
// scalars/email.scalar.spec.ts
import { EmailScalar } from './email.scalar';
import { UserInputError } from '@nestjs/apollo';
describe('EmailScalar', () => {
let scalar: EmailScalar;
beforeEach(() => {
scalar = new EmailScalar();
});
describe('parseValue', () => {
it('accepts valid email', () => {
expect(scalar.parseValue('test@example.com')).toBe('test@example.com');
});
it('throws for invalid email', () => {
expect(() => scalar.parseValue('not-email')).toThrow(UserInputError);
expect(() => scalar.parseValue('')).toThrow(UserInputError);
});
});
describe('parseLiteral', () => {
it('accepts string value AST node', () => {
expect(scalar.parseLiteral({ kind: 'StringValue', value: 'a@b.com' } as any))
.toBe('a@b.com');
});
it('throws for non-string AST nodes', () => {
expect(() => scalar.parseLiteral({ kind: 'IntValue', value: '123' } as any))
.toThrow(UserInputError);
});
});
describe('serialize', () => {
it('serializes valid email string', () => {
expect(scalar.serialize('valid@example.com')).toBe('valid@example.com');
});
});
});Error Format Testing
GraphQL error format matters. Test that errors from your application include the right extensions:
it('includes error code extension on not found', async () => {
const response = await gql(`
query {
user(id: "nonexistent") {
id
}
}
`);
const error = response.body.errors?.[0];
expect(error).toBeDefined();
expect(error.extensions?.code).toBe('NOT_FOUND');
expect(error.extensions?.http?.status).toBe(404);
});
it('does not leak stack traces in production mode', async () => {
// Assuming process.env.NODE_ENV = 'production' in test setup
const response = await gql(`
query {
user(id: "error-trigger") {
id
}
}
`);
const error = response.body.errors?.[0];
expect(error?.extensions?.stacktrace).toBeUndefined();
});Running GraphQL Tests
GraphQL e2e tests live alongside REST e2e tests in the test/ directory. Use the same jest-e2e.json config:
npm run test:e2e -- --testPathPattern=<span class="hljs-string">"graphql"For schema validation tests that don't need a running app, use the schema directly:
import { buildSchema, parse, validate } from 'graphql';
import { printSchema } from 'graphql';
import { GraphQLSchemaFactory } from '@nestjs/graphql';
it('schema is valid', async () => {
const schema = await schemaFactory.create([UsersResolver, PostsResolver]);
const errors = validate(schema, parse(`{ users { id } }`));
expect(errors).toHaveLength(0);
});The GraphQL schema is a contract. Once it's published, breaking changes require versioning. Test your schema as rigorously as you test your resolver logic.
A well-tested GraphQL API still needs production monitoring — schema drift between services, unexpected query patterns causing performance issues, subscription disconnects. HelpMeTest runs continuous query scenarios against your live GraphQL endpoints, catching behavioral regressions before they reach your users.