单元测试策略
一、测试哲学:测什么、不测什么
单元测试的目标是验证业务逻辑正确性,而不是框架本身。
应该测试:
- Service 中的业务规则(余额检查、状态机转换、权限逻辑)
- 边界条件(空值、超出范围的输入、并发冲突)
- 异常路径(未找到记录、外部服务失败时的回滚)
不需要测试:
repository.save()是否真的写入了数据库(这是 TypeORM 的责任)- NestJS 的 DI 是否正常工作(这是框架的责任)
- getter/setter(几乎没有逻辑)
二、TestingModule 基础
typescript
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
describe('UsersService', () => {
let service: UsersService;
let repo: jest.Mocked<Repository<User>>;
beforeEach(async () => {
// 创建 Mock Repository(只包含需要用到的方法)
const mockRepo = {
find: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
softDelete: jest.fn(),
count: jest.fn(),
existsBy: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User), // 替换真实 Repository
useValue: mockRepo,
},
],
}).compile();
service = module.get<UsersService>(UsersService);
repo = module.get(getRepositoryToken(User));
});
// 每次测试后清除所有 mock 调用记录
afterEach(() => jest.clearAllMocks());
});三、测试业务逻辑
正常路径
typescript
describe('create()', () => {
it('成功创建用户', async () => {
const dto: CreateUserDto = {
email: 'test@example.com',
name: '张三',
password: 'Password123!',
};
const createdUser = { id: 1, ...dto, password: 'hashed', role: Role.USER };
repo.findOne.mockResolvedValue(null); // 邮箱未被注册
repo.create.mockReturnValue(createdUser as User);
repo.save.mockResolvedValue(createdUser as User);
const result = await service.create(dto);
expect(result).toEqual(createdUser);
expect(repo.findOne).toHaveBeenCalledWith({ where: { email: dto.email } });
expect(repo.save).toHaveBeenCalledTimes(1);
});
});异常路径(关键!)
typescript
describe('create()', () => {
it('邮箱已注册时抛出 ConflictException', async () => {
const dto = { email: 'existing@example.com', name: '李四', password: '...' };
repo.findOne.mockResolvedValue({ id: 1, email: dto.email } as User);
await expect(service.create(dto)).rejects.toThrow(ConflictException);
expect(repo.save).not.toHaveBeenCalled(); // 不应写入数据库
});
});
describe('findOne()', () => {
it('用户不存在时抛出 NotFoundException', async () => {
repo.findOne.mockResolvedValue(null);
await expect(service.findOne(9999)).rejects.toThrow(NotFoundException);
});
it('返回找到的用户', async () => {
const user = { id: 1, email: 'a@b.com' } as User;
repo.findOne.mockResolvedValue(user);
const result = await service.findOne(1);
expect(result).toBe(user);
expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 1 }, relations: expect.anything() });
});
});四、Mock 策略与技巧
三种 Mock 方式
typescript
// 方式 1:jest.fn() — 完全自定义实现
const mockFn = jest.fn().mockResolvedValue({ id: 1 });
// 方式 2:jest.spyOn() — 监听真实方法(可选择是否替换实现)
const spy = jest.spyOn(service, 'findOne').mockResolvedValue(mockUser);
// 恢复原始实现
spy.mockRestore();
// 方式 3:部分 Mock(保留部分真实方法)
const partialMock: Partial<UsersService> = {
findOne: jest.fn(),
// create 仍然使用真实实现
};Mock 外部依赖(不 Mock 被测对象)
typescript
// ❌ 错误:Mock 被测 Service 自身的方法
jest.spyOn(service, 'create').mockResolvedValue(mockUser);
// 这样测不到任何业务逻辑
// ✅ 正确:Mock 外部依赖(Repository、EmailService 等)
repo.save.mockResolvedValue(mockUser);
// ✅ 正确:Mock 间接依赖(ConfigService)
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('test-secret') },
}Mock 链式调用(QueryBuilder)
typescript
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockUser], 1]),
getOne: jest.fn().mockResolvedValue(mockUser),
};
// 在 repo mock 中
repo.createQueryBuilder = jest.fn().mockReturnValue(mockQueryBuilder);五、测试 Guard、Interceptor、Pipe
测试 Guard
typescript
// roles.guard.spec.ts
import { createMock } from '@golevelup/ts-jest';
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(() => {
reflector = new Reflector();
guard = new RolesGuard(reflector);
});
it('未设置角色要求时放行所有用户', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
const context = createMock<ExecutionContext>();
expect(guard.canActivate(context)).toBe(true);
});
it('用户角色匹配时放行', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
const context = createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({ user: { role: Role.ADMIN } }),
}),
});
expect(guard.canActivate(context)).toBe(true);
});
it('用户角色不匹配时拒绝', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
const context = createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({ user: { role: Role.USER } }),
}),
});
expect(guard.canActivate(context)).toBe(false);
});
});bash
# @golevelup/ts-jest 提供 createMock 帮助函数
npm install -D @golevelup/ts-jest测试 Pipe
typescript
describe('ParseIntPipe', () => {
const pipe = new ParseIntPipe();
it('将字符串数字转为 number', async () => {
const result = await pipe.transform('123', { type: 'param', data: 'id' });
expect(result).toBe(123);
expect(typeof result).toBe('number');
});
it('非数字字符串抛出 BadRequestException', async () => {
await expect(pipe.transform('abc', { type: 'param', data: 'id' }))
.rejects.toThrow(BadRequestException);
});
});测试 Interceptor
typescript
describe('LoggingInterceptor', () => {
let interceptor: LoggingInterceptor;
let mockLogger: jest.Mocked<Logger>;
beforeEach(() => {
mockLogger = { log: jest.fn(), error: jest.fn() } as any;
interceptor = new LoggingInterceptor(mockLogger);
});
it('请求完成后记录日志', done => {
const context = createMock<ExecutionContext>();
const next: CallHandler = {
handle: () => of({ data: 'result' }),
};
interceptor.intercept(context, next).subscribe(() => {
expect(mockLogger.log).toHaveBeenCalled();
done();
});
});
});六、测试事务逻辑
typescript
describe('TransferService', () => {
let service: TransferService;
let dataSource: jest.Mocked<DataSource>;
let mockManager: jest.Mocked<EntityManager>;
beforeEach(async () => {
mockManager = {
findOne: jest.fn(),
decrement: jest.fn(),
increment: jest.fn(),
save: jest.fn(),
} as any;
const mockDataSource = {
transaction: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
TransferService,
{ provide: DataSource, useValue: mockDataSource },
],
}).compile();
service = module.get(TransferService);
dataSource = module.get(DataSource);
});
it('转账成功时执行所有操作', async () => {
const sender = { id: 1, balance: 1000 };
mockManager.findOne.mockResolvedValue(sender);
// Mock transaction 回调立即执行
dataSource.transaction.mockImplementation(async (fn) => fn(mockManager));
await service.transfer(1, 2, 500);
expect(mockManager.decrement).toHaveBeenCalledWith(Account, { id: 1 }, 'balance', 500);
expect(mockManager.increment).toHaveBeenCalledWith(Account, { id: 2 }, 'balance', 500);
});
it('余额不足时抛出异常且不执行转账', async () => {
mockManager.findOne.mockResolvedValue({ id: 1, balance: 100 });
dataSource.transaction.mockImplementation(async (fn) => fn(mockManager));
await expect(service.transfer(1, 2, 500)).rejects.toThrow(BadRequestException);
expect(mockManager.decrement).not.toHaveBeenCalled();
});
});七、覆盖率配置
javascript
// jest.config.js
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: { '^.+\\.(t|j)s$': 'ts-jest' },
collectCoverageFrom: [
'**/*.(t|j)s',
'!**/*.module.ts', // 模块配置文件无逻辑
'!**/*.entity.ts', // Entity 无业务逻辑
'!**/*.dto.ts', // DTO 只有类定义
'!**/main.ts', // 启动文件
'!**/*.interface.ts',
'!**/*.mock.ts',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
// 对关键文件设置更高要求
'./src/auth/auth.service.ts': {
functions: 100,
lines: 90,
},
},
coverageReporters: ['text', 'lcov', 'html'],
testEnvironment: 'node',
};bash
# 运行测试并查看覆盖率报告
npm run test:cov
# 只运行特定文件的测试
npx jest users.service.spec.ts --watch
# 查看未覆盖的代码行(在 coverage/lcov-report/index.html)
open coverage/lcov-report/index.html八、测试最佳实践
typescript
// ✅ 测试名称描述行为,而不是实现
it('余额不足时抛出 BadRequestException', ...) // ✅
it('transfer 方法测试 3', ...) // ❌
// ✅ AAA 结构:Arrange(准备)→ Act(执行)→ Assert(断言)
it('成功创建用户', async () => {
// Arrange
const dto = { email: 'test@example.com', name: '张三', password: '...' };
repo.findOne.mockResolvedValue(null);
repo.save.mockResolvedValue({ id: 1, ...dto });
// Act
const result = await service.create(dto);
// Assert
expect(result.id).toBe(1);
expect(result.email).toBe(dto.email);
});
// ✅ 每个 it 只断言一件事(Single Responsibility)
// ❌ 一个测试验证 10 个不同的条件(测试失败时难以定位原因)
// ✅ 使用 beforeEach 重置 Mock(避免测试间污染)
afterEach(() => jest.clearAllMocks());可运行 Demo:
practice/06-testing— 单元测试(13 个)+ e2e 测试(9 个),npm test直接运行
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
| 测试间互相污染 | Mock 状态未重置 | beforeEach(() => jest.clearAllMocks()) |
Cannot read property of undefined | 依赖未 Mock,注入了 undefined | 检查 providers 数组,确保所有依赖都有对应 useValue |
覆盖率统计包含 node_modules | collectCoverageFrom 未配置 | 设置 "!**/node_modules/**" 排除 |
Guard/Interceptor 测试 ExecutionContext 构造复杂 | NestJS 内部类难以手动构建 | 用 @golevelup/ts-jest 的 createMock<ExecutionContext>() |
| 异步测试超时 | 测试中有未 resolve 的 Promise | 确保 Mock 的方法都 mockResolvedValue(...) 而非留空 |
| e2e 测试数据库状态污染 | 测试间共享同一数据库 | beforeEach 中 DELETE FROM table 清空;或每次测试用唯一邮箱 |