单元测试
本教程共 47 篇 · 第 44 篇 · 更新于 2026-08-09 · 约 14 分钟阅读
本节目标:掌握NestJS单元测试的核心技能,学会用Jest测试服务、控制器、守卫、拦截器等组件,写出可靠的测试用例。
写代码不写测试,就像开车不系安全带。平时感觉没啥,出了事就后悔。NestJS内置了对Jest的支持,写测试非常方便。
Jest基础配置
用Nest CLI创建的项目已经自带Jest配置。看看jest.config.js:
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
};
几个关键配置:
testRegex:匹配.spec.ts结尾的测试文件transform:用ts-jest编译TypeScripttestEnvironment:Node.js环境
package.json里的测试脚本:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
}
}
test:运行所有测试test:watch:监听模式,改代码自动重测test:cov:生成覆盖率报告
测试服务
服务是业务逻辑的核心,测试服务最重要。
基本测试
假设有个UsersService:
@Injectable()
export class UsersService {
constructor(private userRepository: UserRepository) {}
async findAll(): Promise<User[]> {
return this.userRepository.find();
}
async findOne(id: number): Promise<User> {
return this.userRepository.findOne({ where: { id } });
}
}
写测试:
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UserRepository } from './users.repository';
describe('UsersService', () => {
let service: UsersService;
let repository: UserRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: UserRepository,
useValue: {
find: jest.fn(),
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
repository = module.get<UserRepository>(UserRepository);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
Test.createTestingModule()创建测试模块,跟@Module()装饰器的参数一样。用provide和useValue提供Mock的依赖。
测试方法
describe('findAll', () => {
it('should return an array of users', async () => {
const mockUsers = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
];
// 模拟repository.find()的返回值
jest.spyOn(repository, 'find').mockResolvedValue(mockUsers);
const result = await service.findAll();
expect(result).toEqual(mockUsers);
expect(repository.find).toHaveBeenCalled();
});
});
describe('findOne', () => {
it('should return a user by id', async () => {
const mockUser = { id: 1, name: 'John' };
jest.spyOn(repository, 'findOne').mockResolvedValue(mockUser);
const result = await service.findOne(1);
expect(result).toEqual(mockUser);
expect(repository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
});
it('should return null if user not found', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
const result = await service.findOne(999);
expect(result).toBeNull();
});
});
jest.spyOn()拦截方法调用,mockResolvedValue()设置异步返回值。
测试异常
describe('create', () => {
it('should throw ConflictException if email exists', async () => {
const createUserDto = {
name: 'John',
email: 'john@example.com',
password: 'password',
};
jest.spyOn(repository, 'findOne').mockResolvedValue({
id: 1,
email: createUserDto.email,
});
await expect(service.create(createUserDto))
.rejects.toThrow(ConflictException);
});
});
rejects.toThrow()测试异步方法是否抛出异常。
测试控制器
控制器测试跟服务测试类似,但要把控制器的依赖Mock掉。
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('UsersController', () => {
let controller: UsersController;
let service: UsersService;
const mockService = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: mockService,
},
],
}).compile();
controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});
describe('findAll', () => {
it('should return an array of users', async () => {
const mockUsers = [{ id: 1, name: 'John' }];
mockService.findAll.mockResolvedValue(mockUsers);
const result = await controller.findAll();
expect(result).toEqual(mockUsers);
});
});
describe('findOne', () => {
it('should return a user by id', async () => {
const mockUser = { id: 1, name: 'John' };
mockService.findOne.mockResolvedValue(mockUser);
const result = await controller.findOne(1);
expect(result).toEqual(mockUser);
expect(mockService.findOne).toHaveBeenCalledWith(1);
});
});
});
控制器测试重点验证它是否正确调用了服务方法,以及返回值是否正确。
覆盖依赖
有时候不想Mock整个服务,只想替换某个特定的依赖。用overrideProvider():
const module: TestingModule = await Test.createTestingModule({
imports: [UsersModule],
})
.overrideProvider(UsersService)
.useValue({
findAll: () => ['test'],
})
.compile();
overrideProvider()返回一个对象,有三个方法:
useValue:提供一个固定值useClass:提供一个类useFactory:提供一个工厂函数
类似地,还有:
overrideGuard():覆盖守卫overrideInterceptor():覆盖拦截器overridePipe():覆盖管道overrideFilter():覆盖过滤器
覆盖守卫
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: {} },
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
这样就能绕过JWT认证,不用在测试里搞真的Token。
测试守卫
守卫的核心是canActivate()方法,测试它返回true还是false。
import { Test, TestingModule } from '@nestjs/testing';
import { RolesGuard } from './roles.guard';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [RolesGuard, Reflector],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
reflector = module.get<Reflector>(Reflector);
});
it('should allow access when no roles required', () => {
jest.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(undefined);
const context = {
switchToHttp: () => ({
getRequest: () => ({ user: { role: 'user' } }),
}),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as unknown as ExecutionContext;
expect(guard.canActivate(context)).toBe(true);
});
it('should deny access when user lacks required role', () => {
jest.spyOn(reflector, 'getAllAndOverride')
.mockReturnValue(['admin']);
const context = {
switchToHttp: () => ({
getRequest: () => ({ user: { role: 'user' } }),
}),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as unknown as ExecutionContext;
expect(() => guard.canActivate(context))
.toThrow(ForbiddenException);
});
});
Mock一个假的ExecutionContext,模拟不同的请求场景。
测试拦截器
拦截器返回Observable,测试时用of()创建测试流。
import { Test, TestingModule } from '@nestjs/testing';
import { TransformInterceptor } from './transform.interceptor';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';
describe('TransformInterceptor', () => {
let interceptor: TransformInterceptor<any>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TransformInterceptor],
}).compile();
interceptor = module.get<TransformInterceptor<any>>(
TransformInterceptor
);
});
it('should transform response', (done) => {
const context = {
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
} as ExecutionContext;
const next: CallHandler = {
handle: () => of({ name: 'John' }),
};
interceptor.intercept(context, next).subscribe({
next: (result) => {
expect(result).toEqual({
code: 200,
message: 'Success',
data: { name: 'John' },
timestamp: expect.any(String),
});
done();
},
});
});
});
done回调告诉Jest这是异步测试,等Observable发出数据后再断言。
测试管道
管道测试相对简单,直接调用transform()方法。
import { ValidationPipe } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
describe('CreateUserDto Validation', () => {
let pipe: ValidationPipe;
beforeEach(() => {
pipe = new ValidationPipe();
});
it('should pass validation with valid data', async () => {
const dto = {
name: 'John',
email: 'john@example.com',
password: 'password123',
};
const result = await pipe.transform(dto, {
type: 'body',
metatype: CreateUserDto,
});
expect(result).toEqual(dto);
});
it('should fail validation with invalid email', async () => {
const dto = {
name: 'John',
email: 'invalid-email',
password: 'password123',
};
await expect(
pipe.transform(dto, {
type: 'body',
metatype: CreateUserDto,
})
).rejects.toThrow();
});
});
自动Mock
依赖太多时,一个个Mock太麻烦。用useMocker()自动Mock所有未提供的依赖:
import { ModuleMocker, MockMetadata } from 'jest-mock';
const moduleMocker = new ModuleMocker(global);
describe('CatsController', () => {
let controller: CatsController;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
})
.useMocker((token) => {
// 特定服务的Mock
if (token === CatsService) {
return {
findAll: jest.fn().mockResolvedValue(['test1', 'test2'])
};
}
// 其他依赖自动生成Mock
if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(
token
) as MockMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock();
}
})
.compile();
controller = moduleRef.get(CatsController);
});
});
useMocker()接收一个工厂函数,对每个未提供的依赖自动创建Mock。
测试覆盖率
运行覆盖率测试:
npm run test:cov
会生成覆盖率报告:
------------------------|----------|----------|----------|----------|
File | % Stmts | % Branch | % Funcs | % Lines |
------------------------|----------|----------|----------|----------|
All files | 90.5 | 85.2 | 92.3 | 90.1 |
users | 95.2 | 90.0 | 100.0 | 95.0 |
users.controller.ts | 100.0 | 100.0 | 100.0 | 100.0 |
users.service.ts | 92.3 | 85.7 | 100.0 | 92.0 |
------------------------|----------|----------|----------|----------|
四个指标:
- Stmts(语句覆盖率):执行了多少语句
- Branch(分支覆盖率):if/else走了多少分支
- Funcs(函数覆盖率):调用了多少函数
- Lines(行覆盖率):执行了多少行代码
Tip覆盖率不是越高越好,但80%以上是个合理的目标。别为了凑数字写无意义的测试。
最佳实践
用describe分组
describe('UsersService', () => {
describe('findAll', () => {
it('should return users', () => {});
});
describe('findOne', () => {
it('should return user', () => {});
it('should return null if not found', () => {});
});
});
按方法分组,测试报告更清晰。
清理Mock
afterEach(() => {
jest.clearAllMocks();
});
每个测试后清理Mock状态,避免测试间互相影响。
类型安全的Mock
const mockService = {
findAll: jest.fn() as jest.MockedFunction<UsersService['findAll']>,
};
这样Mock就有正确的类型签名,IDE能给你提示。
测试文件命名
测试文件放在被测试文件旁边,命名为xxx.spec.ts。比如users.service.ts对应users.service.spec.ts。
Note单元测试只测试单个类或方法,把所有依赖都Mock掉。如果想测试整个模块的集成,用E2E测试(下一章)。
小结
这一章学了NestJS单元测试的核心技能:
Jest配置:Nest CLI已经帮你搞好了,直接用就行。
测试服务:用Test.createTestingModule()创建测试模块,Mock掉依赖,测试业务逻辑。
测试控制器:Mock服务,验证控制器是否正确调用服务方法。
覆盖依赖:用overrideProvider()等方法替换特定的依赖。
测试守卫、拦截器、管道:MockExecutionContext和CallHandler,测试它们的逻辑。
自动Mock:依赖太多时用useMocker()自动生成Mock。
测试覆盖率:用npm run test:cov查看覆盖率报告。
单元测试是代码质量的保障。写好单元测试,改代码的时候心里有底,不怕改坏。下一章我们聊E2E测试。