NestJS Testing: Unit, Integration, and E2E Testing Guide

NestJS Testing: Unit, Integration, and E2E Testing Guide

NestJS comes with Jest pre-configured and a full testing module built into the framework. Most teams generate the boilerplate, delete the placeholder it('should be defined') tests, and ship anyway.

The testing infrastructure is already there. Here's how to actually use it.

NestJS Testing Architecture

NestJS testing revolves around @nestjs/testing, which lets you create a full application module in test context with dependency injection intact:

import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [UsersService],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

This is the starting point. It compiles your module and wires up the dependency injection tree exactly as in production.

Layer 1: Unit Testing Services

Services contain your business logic. Test them in isolation by mocking their dependencies.

// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './user.entity';

const mockUsersRepository = {
  find: jest.fn(),
  findOneBy: jest.fn(),
  save: jest.fn(),
  delete: jest.fn(),
};

describe('UsersService', () => {
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: mockUsersRepository,
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    jest.clearAllMocks();
  });

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const expectedUsers = [
        { id: 1, email: 'alice@test.com' },
        { id: 2, email: 'bob@test.com' },
      ];
      mockUsersRepository.find.mockResolvedValue(expectedUsers);

      const result = await service.findAll();

      expect(result).toEqual(expectedUsers);
      expect(mockUsersRepository.find).toHaveBeenCalledTimes(1);
    });
  });

  describe('findOne', () => {
    it('should return a user when found', async () => {
      const user = { id: 1, email: 'alice@test.com' };
      mockUsersRepository.findOneBy.mockResolvedValue(user);

      const result = await service.findOne(1);
      expect(result).toEqual(user);
    });

    it('should throw NotFoundException when user does not exist', async () => {
      mockUsersRepository.findOneBy.mockResolvedValue(null);

      await expect(service.findOne(99999)).rejects.toThrow('User not found');
    });
  });

  describe('create', () => {
    it('should hash password before saving', async () => {
      const dto = { email: 'new@test.com', password: 'plaintext123' };
      mockUsersRepository.save.mockResolvedValue({ id: 3, ...dto });

      await service.create(dto);

      const savedUser = mockUsersRepository.save.mock.calls[0][0];
      expect(savedUser.password).not.toBe('plaintext123');
      expect(savedUser.password).toMatch(/^\$2[ab]\$\d+\$/);  // bcrypt hash pattern
    });
  });
});

Layer 2: Testing Controllers

Controllers handle HTTP concerns. Test them with a mocked service.

// users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';

const mockUsersService = {
  findAll: jest.fn(),
  findOne: jest.fn(),
  create: jest.fn(),
  update: jest.fn(),
  remove: jest.fn(),
};

describe('UsersController', () => {
  let controller: UsersController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        { provide: UsersService, useValue: mockUsersService },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    jest.clearAllMocks();
  });

  describe('findAll', () => {
    it('should return users from service', async () => {
      const users = [{ id: 1, email: 'alice@test.com' }];
      mockUsersService.findAll.mockResolvedValue(users);

      const result = await controller.findAll();
      expect(result).toEqual(users);
    });
  });

  describe('create', () => {
    it('should call service with DTO', async () => {
      const dto: CreateUserDto = { email: 'new@test.com', password: 'pass123' };
      const created = { id: 5, email: 'new@test.com' };
      mockUsersService.create.mockResolvedValue(created);

      const result = await controller.create(dto);

      expect(mockUsersService.create).toHaveBeenCalledWith(dto);
      expect(result).toEqual(created);
    });
  });
});

Layer 3: Testing Guards and Interceptors

Guards and interceptors are the hardest NestJS pieces to test. Use ExecutionContext mocks:

// auth/jwt-auth.guard.spec.ts
import { JwtAuthGuard } from './jwt-auth.guard';
import { JwtService } from '@nestjs/jwt';
import { ExecutionContext } from '@nestjs/common';

describe('JwtAuthGuard', () => {
  let guard: JwtAuthGuard;
  let jwtService: JwtService;

  beforeEach(() => {
    jwtService = { verify: jest.fn() } as any;
    guard = new JwtAuthGuard(jwtService);
  });

  function createMockContext(token?: string): ExecutionContext {
    return {
      switchToHttp: () => ({
        getRequest: () => ({
          headers: token ? { authorization: `Bearer ${token}` } : {},
        }),
      }),
    } as ExecutionContext;
  }

  it('should allow requests with valid tokens', async () => {
    jwtService.verify = jest.fn().mockReturnValue({ sub: 1, email: 'user@test.com' });
    const ctx = createMockContext('valid.jwt.token');

    const canActivate = await guard.canActivate(ctx);
    expect(canActivate).toBe(true);
  });

  it('should reject requests without Authorization header', async () => {
    const ctx = createMockContext();
    await expect(guard.canActivate(ctx)).rejects.toThrow('Unauthorized');
  });

  it('should reject requests with expired tokens', async () => {
    jwtService.verify = jest.fn().mockImplementation(() => {
      throw new Error('jwt expired');
    });
    const ctx = createMockContext('expired.jwt.token');

    await expect(guard.canActivate(ctx)).rejects.toThrow('Unauthorized');
  });
});

Layer 4: Testing Pipes and Validation

NestJS pipes transform and validate input. Test them directly:

// validation/parse-positive-int.pipe.spec.ts
import { ParsePositiveIntPipe } from './parse-positive-int.pipe';
import { BadRequestException } from '@nestjs/common';

describe('ParsePositiveIntPipe', () => {
  let pipe: ParsePositiveIntPipe;

  beforeEach(() => {
    pipe = new ParsePositiveIntPipe();
  });

  it('should parse valid positive integer strings', () => {
    expect(pipe.transform('5')).toBe(5);
    expect(pipe.transform('100')).toBe(100);
  });

  it('should throw BadRequestException for zero', () => {
    expect(() => pipe.transform('0')).toThrow(BadRequestException);
  });

  it('should throw BadRequestException for negative numbers', () => {
    expect(() => pipe.transform('-3')).toThrow(BadRequestException);
  });

  it('should throw BadRequestException for non-numeric strings', () => {
    expect(() => pipe.transform('abc')).toThrow(BadRequestException);
    expect(() => pipe.transform('')).toThrow(BadRequestException);
  });
});

Layer 5: E2E Tests with Supertest

E2E tests run the full NestJS application against a real HTTP server:

// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { TypeOrmModule } from '@nestjs/typeorm';

describe('Users (e2e)', () => {
  let app: INestApplication;
  let authToken: string;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideModule(TypeOrmModule)
      .useModule(TypeOrmModule.forRoot({ /* test DB config */ }))
      .compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
    await app.init();

    // Set up auth token for protected route tests
    const loginResponse = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email: 'test@test.com', password: 'pass123' });
    authToken = loginResponse.body.access_token;
  });

  afterAll(async () => {
    await app.close();
  });

  describe('GET /users', () => {
    it('should return 401 without token', () => {
      return request(app.getHttpServer())
        .get('/users')
        .expect(401);
    });

    it('should return users with valid token', () => {
      return request(app.getHttpServer())
        .get('/users')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200)
        .expect(res => {
          expect(Array.isArray(res.body)).toBe(true);
        });
    });
  });

  describe('POST /users', () => {
    it('should create user with valid data', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ email: 'new@test.com', password: 'securepass123' })
        .expect(201)
        .expect(res => {
          expect(res.body.email).toBe('new@test.com');
          expect(res.body.password).toBeUndefined(); // Should not expose password
        });
    });

    it('should return 400 with invalid email', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ email: 'not-an-email', password: 'pass123' })
        .expect(400)
        .expect(res => {
          expect(res.body.message).toContain('email');
        });
    });
  });
});

Testing NestJS with a Database

Use an in-memory SQLite database for integration tests to keep them fast and isolated:

// test/database.module.ts
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../src/users/user.entity';

export const TestDatabaseModule = TypeOrmModule.forRoot({
  type: 'sqlite',
  database: ':memory:',
  entities: [User],
  synchronize: true,
  dropSchema: true,
});

What Tests Miss

Your Jest suite proves your NestJS modules are wired correctly in isolation. It doesn't prove your deployed application handles real traffic.

Production NestJS failures:

  • Environment variable gaps — a ConfigService key is missing in production but present in test .env, causing silent undefined values
  • Middleware ordering — your global pipes and interceptors run in a different order than expected in a deployed cluster
  • WebSocket connection drops — NestJS gateway logic works in test but fails under load in production
  • Circular dependency warnings becoming errors — rare in test, surfaces in production at scale

Production Monitoring for NestJS APIs

HelpMeTest lets you run behavioral tests against your deployed NestJS service continuously:

Test: user registration flow
POST /users with email and password
Status is 201
Body contains id and email
GET /users/:id returns the created user
Response time under 500ms

Tests run on a schedule. If your guards stop working after a JWT library update, your validation pipe silently passes bad data, or an endpoint returns 500 under load, you find out immediately.

Free tier: 10 tests, unlimited health checks. Try HelpMeTest →

NestJS Testing Checklist

  • Service unit tests: every public method, error cases, dependency mocks
  • Controller unit tests: routing to correct service method, response shape
  • Guard unit tests: allow valid, reject invalid, reject missing credentials
  • Pipe unit tests: transform valid inputs, throw on invalid inputs
  • Interceptor tests: transforms response correctly, handles errors
  • E2E tests with Supertest: full HTTP request/response cycles
  • Validation pipe tested with invalid data at the HTTP level
  • Database integration tests with test DB (not production)
  • Production monitoring: behavioral tests after every deploy

The @nestjs/testing module is there. Use it.

Read more