Skip to main content

How to test a NestJS application?

NestJS testing uses @nestjs/testing to build isolated DI containers for unit tests and boots the full app for e2e tests via Supertest.

Theory

TL;DR

  • Unit tests create a lightweight module with Test.createTestingModule(), swap real deps with jest.fn() mocks, and run in milliseconds
  • E2e tests boot the full Express/Fastify server, send real HTTP requests through Supertest, and verify end-to-end flows
  • Override guards, pipes, and interceptors with .overrideGuard(), .overridePipe(), .overrideInterceptor()
  • Unit test pure service logic; e2e test auth flows, validation, and multi-step user journeys
  • Always await app.init() before any e2e request, or you get ECONNREFUSED

Quick example

typescript
// Unit test: mock the TypeORM repo, test service logic in isolation import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { UsersService } from './users.service'; import { User } from './entities/user.entity'; describe('UsersService', () => { let service: UsersService; const mockRepo = { find: jest.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]), findOneBy: jest.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ UsersService, { provide: getRepositoryToken(User), useValue: mockRepo }, ], }).compile(); service = module.get<UsersService>(UsersService); }); it('returns all users', async () => { const result = await service.findAll(); expect(result).toEqual([{ id: 1, name: 'Alice' }]); }); }); // ✓ returns all users (4ms)

Test.createTestingModule() builds a DI container that mirrors your real module but skips the HTTP server entirely. The mock repository replaces the real TypeORM one, so no database connection is needed.

Unit tests vs e2e tests

Unit tests isolate one provider. You override only the dependencies that provider needs, nothing else. The test container resolves providers via Nest's reflect-metadata scanner, exactly like production, but without any I/O. A well-written unit test runs in under 50ms.

E2e tests call module.createNestApplication(), which spins up a real Express or Fastify server. Supertest binds to that server and sends actual HTTP requests. Guards, pipes, interceptors, and middleware all run. This is the only way to test validation errors, auth flows, or anything that happens at the HTTP layer.

When to use each

  • Service with DB queries: unit test with a mocked repository
  • Controller with validation: unit test with a mocked service and a real ValidationPipe
  • Login flow (POST /auth → JWT → GET /profile): e2e test
  • Guard logic in isolation: unit test with a mocked ExecutionContext
  • Full user journey with real DB writes: e2e test with a test database

If the test needs a real network call or a real database, make it an e2e test. Otherwise, keep it a unit test.

How the module system works

Test.createTestingModule() creates a lightweight DI container. It scans providers using Nest's reflect-metadata decorators, resolves injection tokens, and wires everything together. The key difference from NestFactory.create() is that it never starts an HTTP listener.

When you provide { provide: getRepositoryToken(User), useValue: mockRepo }, Nest replaces every injection point for that token with your mock. This works because TypeORM's getRepositoryToken() returns a predictable injection token string. The mechanism is the same as standard NestJS dependency injection, with tokens mapping to providers at compile time.

For e2e tests, createNestApplication() runs the same bootstrap logic as your main.ts. If you use app.useGlobalPipes(new ValidationPipe()) in production, you need to add it in your test setup too. Skip this and your e2e validation tests will pass when they should fail.

Overriding guards, pipes, and interceptors

typescript
const module = await Test.createTestingModule({ controllers: [UsersController], providers: [UsersService], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) .overridePipe(ParseIntPipe) .useValue({ transform: (val) => parseInt(val) }) .compile();

.overrideGuard() targets route-level guards after DI resolution. It differs from overrideProvider(), which swaps any injectable by token. Use overrideGuard for guards, overrideInterceptor for interceptors, and overrideProvider for services, repositories, or config values.

Common mistakes

Forgetting await app.init()

typescript
// Wrong - server is not listening yet app = moduleFixture.createNestApplication(); request(app.getHttpServer()).get('/users'); // ECONNREFUSED // Correct await app.init(); request(app.getHttpServer()).get('/users'); // 200

Skipping global pipes in e2e setup. If your app uses ValidationPipe globally, add it in beforeAll:

typescript
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.init();

Without this, POST requests with invalid bodies return 201 instead of 400. Tests pass, but production rejects the same input.

Shared test database without cleanup. Tests that write to a shared database leave data behind. The next test finds unexpected rows and fails. Fix: truncate tables in beforeEach, or use a separate schema per test run. This one bites every team eventually, usually right before a release.

Mocking the entire module instead of specific providers:

typescript
// Wrong - loses DI context, module.get() fails jest.mock('./users.service'); // Correct - mock only what the tested unit needs { provide: UsersService, useValue: { findAll: jest.fn() } }

Testing REQUEST-scoped providers in unit tests. REQUEST-scoped providers recreate on every HTTP call. In unit tests there is no HTTP context, so module.get() on a REQUEST-scoped provider fails. Use e2e tests for those, or replace them with a DEFAULT-scoped mock via overrideProvider. NestJS v10 added overrideScope for more precise control here.

Real-world usage

  • NestJS CLI generates *.spec.ts files with @nestjs/testing setup via nest new project
  • Prisma projects mock PrismaService in unit tests, use a $transaction spy for write flows
  • BullMQ queues: overrideProvider(Queue).useValue(mockQueue) tests processors without Redis
  • GraphQL e2e: import GqlModule.forRoot and send raw query strings through Supertest
  • Stripe webhooks: mock stripe.webhookConstructEvent() to avoid real charges in CI

Follow-up questions

Q: How do you mock a dynamic provider like ConfigService in NestJS 10+?
A: Use useFactory with injection: { provide: 'API_KEY', useFactory: (config: ConfigService) => config.get('KEY'), inject: [ConfigService] }. Or override ConfigService directly with useValue: { get: jest.fn().mockReturnValue('test-key') }.

Q: What is the difference between overrideProvider and overrideGuard?
A: overrideProvider swaps any injectable by its injection token, including services and repositories. overrideGuard targets guards after DI resolution and can replace route-bound guards that do not have a straightforward token.

Q: How do you test a microservice with Redis transport without a real Redis instance?
A: Mock the client entirely: overrideProvider(getClientToken()).useValue({ send: jest.fn() }). The goal is to never require a real broker connection in unit or CI tests.

Q: Why does an e2e test pass locally but fail in CI?
A: Most often it is a missing globalSetup for DB schema sync, a different DATABASE_URL env var, or a race condition in beforeAll. Add explicit schema migration in globalSetup and verify all env vars in your CI config.

Q: How does REQUEST scope affect mocking in unit tests, and how do you handle it?
A: REQUEST-scoped providers recreate on each HTTP request. In a unit test there is no request, so Nest cannot resolve them normally. You either test them in e2e where a real HTTP request provides the context, or replace them with a DEFAULT-scoped mock via overrideProvider. NestJS v10 introduced overrideScope to handle this case without switching to e2e.

Examples

Basic service unit test with NotFoundException

typescript
// users/users.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UsersService } from './users.service'; import { User } from './entities/user.entity'; import { NotFoundException } from '@nestjs/common'; describe('UsersService', () => { let service: UsersService; let repo: jest.Mocked<Repository<User>>; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ UsersService, { provide: getRepositoryToken(User), useValue: { findOneBy: jest.fn(), create: jest.fn(), save: jest.fn(), }, }, ], }).compile(); service = module.get<UsersService>(UsersService); repo = module.get(getRepositoryToken(User)); }); it('returns a user when found', async () => { const user = { id: 1, name: 'Alice', email: 'alice@test.com' } as User; repo.findOneBy.mockResolvedValue(user); const result = await service.findOne(1); expect(result).toEqual(user); expect(repo.findOneBy).toHaveBeenCalledWith({ id: 1 }); }); it('throws NotFoundException when user does not exist', async () => { repo.findOneBy.mockResolvedValue(null); await expect(service.findOne(99)).rejects.toThrow(NotFoundException); }); });

repo.findOneBy.mockResolvedValue(null) simulates a missing record. The test verifies the service throws NotFoundException, not just returns null. That is the contract the rest of the codebase depends on.

E2e test with auth flow and ValidationPipe

typescript
// test/auth.e2e-spec.ts import { Test } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; describe('Auth (e2e)', () => { let app: INestApplication; beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = module.createNestApplication(); // Mirror your main.ts setup exactly app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.init(); }); afterAll(async () => await app.close()); it('POST /auth/register returns 201 with access_token', async () => { const res = await request(app.getHttpServer()) .post('/auth/register') .send({ email: 'test@test.com', password: 'password123' }); expect(res.status).toBe(201); expect(res.body.access_token).toBeDefined(); }); it('POST /auth/register returns 400 with invalid email', async () => { const res = await request(app.getHttpServer()) .post('/auth/register') .send({ email: 'not-an-email', password: 'password123' }); expect(res.status).toBe(400); }); }); // ✓ POST /auth/register returns 201 with access_token (45ms)

afterAll(async () => await app.close()) is not optional. Without it, Jest hangs after the suite finishes because the HTTP server is still listening. Every e2e file needs this.

Controller unit test with a mocked guard

typescript
// users/users.controller.spec.ts import { Test } from '@nestjs/testing'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; describe('UsersController', () => { let controller: UsersController; let service: jest.Mocked<UsersService>; beforeEach(async () => { const module = await Test.createTestingModule({ controllers: [UsersController], providers: [ { provide: UsersService, useValue: { findAll: jest.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]), findOne: jest.fn(), }, }, ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) .compile(); controller = module.get<UsersController>(UsersController); service = module.get(UsersService); }); it('calls service.findAll and returns its result', async () => { const result = await controller.findAll(); expect(result).toEqual([{ id: 1, name: 'Alice' }]); expect(service.findAll).toHaveBeenCalled(); }); });

Without .overrideGuard(JwtAuthGuard), the guard runs and throws because there is no JWT token in a unit test. The mock { canActivate: () => true } bypasses auth entirely so you can focus on what the controller actually does with the service response.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?