Testing NestJS GraphQL Resolvers, Subscriptions, and Schema Validation

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.

Read more