Unit Testing NestJS Services and Controllers with Jest
Unit testing in NestJS is not complicated, but it does require understanding how the framework's dependency injection container works. Once you get that, writing fast, isolated tests for services, controllers, and providers becomes straightforward. This guide walks through the real mechanics — no hand-waving.
How NestJS Testing Works
NestJS ships with @nestjs/testing, which provides a TestingModule builder. This builder lets you construct a minimal NestJS module — just the pieces you need for a given test — without booting a full HTTP server. Tests run in milliseconds, not seconds.
The key insight: NestJS uses a DI container. To test a service in isolation, you swap its real dependencies for mocks inside the test module. The service under test never knows the difference.
Setting Up a Test Module
Start with a typical service:
// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return user;
}
async create(email: string, name: string): Promise<User> {
const user = this.usersRepository.create({ email, name });
return this.usersRepository.save(user);
}
}The service depends on a TypeORM repository. In unit tests, you replace that repository with a mock object:
// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './user.entity';
describe('UsersService', () => {
let service: UsersService;
let mockRepository: {
findOne: jest.Mock;
create: jest.Mock;
save: jest.Mock;
};
beforeEach(async () => {
mockRepository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: mockRepository,
},
],
}).compile();
service = module.get<UsersService>(UsersService);
});
afterEach(() => {
jest.clearAllMocks();
});
});getRepositoryToken(User) returns the injection token NestJS uses internally for the TypeORM repository. You override it with useValue — a plain object with mocked methods. Test.createTestingModule().compile() boots the DI container with your overrides in place.
Writing Service Tests
With the module set up, write the actual test cases:
describe('findOne', () => {
it('returns a user when found', async () => {
const expectedUser: User = { id: 1, email: 'test@example.com', name: 'Test User' } as User;
mockRepository.findOne.mockResolvedValue(expectedUser);
const result = await service.findOne(1);
expect(result).toEqual(expectedUser);
expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
expect(mockRepository.findOne).toHaveBeenCalledTimes(1);
});
it('throws NotFoundException when user does not exist', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOne(99)).rejects.toThrow(NotFoundException);
await expect(service.findOne(99)).rejects.toThrow('User 99 not found');
});
});
describe('create', () => {
it('creates and saves a new user', async () => {
const newUser: User = { id: 1, email: 'new@example.com', name: 'New User' } as User;
mockRepository.create.mockReturnValue(newUser);
mockRepository.save.mockResolvedValue(newUser);
const result = await service.create('new@example.com', 'New User');
expect(result).toEqual(newUser);
expect(mockRepository.create).toHaveBeenCalledWith({
email: 'new@example.com',
name: 'New User',
});
expect(mockRepository.save).toHaveBeenCalledWith(newUser);
});
});Notice mockResolvedValue for async operations and mockReturnValue for synchronous ones. This distinction matters — using the wrong one gives you a resolved promise object rather than its value, causing subtle assertion failures.
Testing Controllers
Controllers depend on services. Mock the service, test the controller's request handling logic:
// users.controller.ts
import { Controller, Get, Post, Body, Param, ParseIntPipe } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto.email, createUserDto.name);
}
}// users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { NotFoundException } from '@nestjs/common';
describe('UsersController', () => {
let controller: UsersController;
let mockUsersService: { findOne: jest.Mock; create: jest.Mock };
beforeEach(async () => {
mockUsersService = {
findOne: jest.fn(),
create: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: mockUsersService,
},
],
}).compile();
controller = module.get<UsersController>(UsersController);
});
describe('findOne', () => {
it('returns user from service', async () => {
const user = { id: 1, email: 'a@b.com', name: 'A' };
mockUsersService.findOne.mockResolvedValue(user);
const result = await controller.findOne(1);
expect(result).toEqual(user);
expect(mockUsersService.findOne).toHaveBeenCalledWith(1);
});
it('propagates NotFoundException from service', async () => {
mockUsersService.findOne.mockRejectedValue(new NotFoundException());
await expect(controller.findOne(99)).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('creates user via service and returns result', async () => {
const created = { id: 2, email: 'b@c.com', name: 'B' };
mockUsersService.create.mockResolvedValue(created);
const result = await controller.create({ email: 'b@c.com', name: 'B' });
expect(result).toEqual(created);
expect(mockUsersService.create).toHaveBeenCalledWith('b@c.com', 'B');
});
});
});Controller unit tests focus on: does the controller call the right service method with the right arguments, and does it return or propagate whatever the service gives back? HTTP-level concerns (status codes, headers, request parsing) belong in integration or e2e tests.
Mocking Multiple Dependencies
Real services often have several dependencies. Add them all to providers:
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: UsersService,
useValue: { findOne: jest.fn() },
},
{
provide: EmailService,
useValue: { sendConfirmation: jest.fn() },
},
{
provide: getRepositoryToken(Order),
useValue: { create: jest.fn(), save: jest.fn(), findOne: jest.fn() },
},
],
}).compile();Keep mock objects minimal — only implement the methods the service under test actually calls. Adding unused mock methods is noise that makes tests harder to read.
Spying Instead of Replacing
Sometimes you want to call through to the real implementation but spy on calls. Use jest.spyOn:
const findOneSpy = jest.spyOn(service, 'findOne').mockResolvedValue(mockUser);
// or to call through:
const findOneSpy = jest.spyOn(service, 'findOne');Spies are useful when testing a service method that calls another method on the same service instance.
Testing Providers with useFactory
Some providers use factories with async initialization:
{
provide: ConfigService,
useFactory: () => ({
get: jest.fn().mockReturnValue('test-value'),
}),
}useFactory works identically to useValue for test purposes — the factory just gives you a hook to run setup code if needed.
Common Mistakes
Not calling jest.clearAllMocks() in afterEach. Mock call counts bleed between tests, causing false positives in toHaveBeenCalledTimes assertions.
Forgetting to await module.compile(). It returns a promise. Without await, module.get() fails with a cryptic error.
Mocking at the wrong level. Mock the direct dependency of the thing you're testing — not its transitive dependencies. If you're testing OrdersService which depends on UsersService, mock UsersService. Don't reach into UsersService's dependencies.
Testing implementation details. If a test breaks every time you refactor internals without changing behavior, it's testing too deep. Test inputs and outputs, not the sequence of internal method calls.
Structuring Test Files
Keep test files next to the source:
src/
users/
users.service.ts
users.service.spec.ts
users.controller.ts
users.controller.spec.tsJest's default config in NestJS (via nest-cli.json) picks up *.spec.ts files automatically. Run all unit tests with:
npm run test
<span class="hljs-comment"># or
npx jest --testPathPattern=<span class="hljs-string">"spec"Watch mode during development:
npm run test:watchWhat to Test in Unit Tests vs Other Levels
Unit tests cover: business logic branches, error conditions, input validation within service methods, edge cases in data transformation.
Skip in unit tests: HTTP status codes, request parsing, actual database queries, authentication flows, cross-service integration. Those belong in integration or e2e tests.
A good unit test suite runs in under 10 seconds for a medium-sized application. If yours is slower, something is wrong — you're probably hitting real infrastructure or doing too much setup.
Once your unit tests are green and your NestJS app is deployed, you'll want continuous test coverage to catch regressions in production. HelpMeTest runs scheduled test scenarios against your live endpoints — catching issues that only appear in production environments, before your users do.