NestJS 测试实战指南:面向初学者的完整实践
面向第一次写 Nest 测试的你:本文会从“为什么测试”、“测试有哪些类型”讲起,再到环境配置、单测/集成测/e2e 的完整示例,结合真实代码让你学会在 Nest 项目中写可靠的测试。
1. 测试在项目中的价值
| 维度 | 解决的问题 |
|---|---|
| 产品质量 | 及时发现逻辑错误、回归缺陷,避免“上线即翻车” |
| 迭代效率 | 快速验证新功能/重构不会破坏旧功能 |
| 代码可靠性 | 自动化测试让团队对交付更有信心,避免人肉测试带来的疏漏 |
| 文档作用 | 测试用例本身就是活文档,告诉后来者“这个函数应该怎么用、输入输出是什么” |
在我们这个项目里(Nest + TypeORM + PostgreSQL),测试主要覆盖:
- 单元测试(Unit Test):验证 Service 层逻辑、DTO 校验、异常处理。
- 集成测试(Integration Test):多个模块协同工作时的行为(例如库存模块调用多个 Repository)。
- 端到端测试(E2E Test):从 HTTP 请求到数据库返回的全链路验证。
2. NestJS 测试体系概览
Nest 默认使用 Jest 作为测试框架,自带 @nestjs/testing 模块帮助我们构建测试模块。
2.1 Jest 的特点
- 零配置即可在 TS 项目中运行(Nest CLI 已预置)。
- 支持快照、Mock、并行执行。
- 与 TS、Babel、ts-jest 无缝组合。
2.2 测试分类
| 类型 | 粒度 | 优点 | 场景 |
|---|---|---|---|
| 单元测试 | 独立类/函数 | 快速、定位精确 | DTO 校验、Service 方法、简单业务逻辑 |
| 集成测试 | 多个模块组合 | 更贴近真实 | 复杂业务流程(如库存调整) |
| E2E 测试 | 整个应用 | 最真实 | REST API、GraphQL 端点、外部接口 |
3. 环境准备
3.1 基础依赖
Nest 项目默认包含测试配置,如需新增:
npm install --save-dev jest @types/jest ts-jest
npm install --save-dev supertest @types/supertest在 package.json 里已自带脚本:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:cov": "jest --coverage"
}
}### 3.2 `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',
};### 3.3 `test/jest-e2e.json`
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "./",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testEnvironment": "node"
}
4. 单元测试:Service 层实践
以 ItemsService 为例,如何 mock Repository 并验证业务逻辑。
4.1 Mock Repository & QueryBuilder
/* eslint-disable @typescript-eslint/unbound-method */
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ItemsService } from './items.service';
import { Item } from './entities/item.entity';
type MockRepository<T extends ObjectLiteral> = Partial<
Record<'findOne' | 'create' | 'save' | 'remove' | 'createQueryBuilder' | 'find', jest.Mock>
> & {
createQueryBuilder?: jest.Mocked<SelectQueryBuilder<T>>;
};
const createQueryBuilderMock = <T extends ObjectLiteral>(result: T[]) => {
const qb: Partial<jest.Mocked<SelectQueryBuilder<T>>> = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(result),
getOne: jest.fn().mockResolvedValue(result[0] ?? null),
distinct: jest.fn().mockReturnThis(),
};
return qb as jest.Mocked<SelectQueryBuilder<T>>;
};
describe('ItemsService', () => {
let service: ItemsService;
let itemsRepository: MockRepository<Item>;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
ItemsService,
{
provide: getRepositoryToken(Item),
useValue: {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
},
},
// 其他依赖的 Repository ...
],
}).compile();
service = moduleRef.get(ItemsService);
itemsRepository = moduleRef.get(getRepositoryToken(Item));
});
it('should list items with filters applied', async () => {
const qb = createQueryBuilderMock<Item>([{ id: 'item-1' } as Item]);
(itemsRepository.createQueryBuilder as jest.Mock).mockReturnValue(qb);
const result = await service.findAll({ keyword: '牛奶' });
expect(itemsRepository.createQueryBuilder).toHaveBeenCalledWith('item');
expect(qb.andWhere).toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('should create item successfully', async () => {
(itemsRepository.createQueryBuilder as jest.Mock).mockReturnValue(createQueryBuilderMock<Item>([]));
(itemsRepository.create as jest.Mock).mockReturnValue({ id: 'item-1' });
(itemsRepository.save as jest.Mock).mockImplementation((value) => value);
const result = await service.create({
categoryId: 'cat-1',
name: '牛奶',
unit: '瓶',
});
expect(result.id).toBe('item-1');
});
});**关键点:**
- 使用 `getRepositoryToken(Entity)` 获取对应 Repository。
- 通过 jest 提供的 `jest.fn()`、`mockReturnValue` 等实现 Mock。
- 复杂查询(QueryBuilder)需要额外构造 `createQueryBuilderMock`。
5. E2E 测试:Supertest + SQLite 内存库
验证整个 HTTP 请求 → Service → Repository → DB → 返回响应的流程。
5.1 示例:test/items.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken, TypeOrmModule } from '@nestjs/typeorm';
import request from 'supertest';
import { ItemsModule } from '../src/modules/items/items.module';
import { Item } from '../src/modules/items/entities/item.entity';
import { Category } from '../src/modules/categories/entities/category.entity';
import { Location } from '../src/modules/locations/entities/location.entity';
import { Tag } from '../src/modules/tags/entities/tag.entity';
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
import { TransformInterceptor } from '../src/common/interceptors/transform.interceptor';
describe('ItemsModule (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
entities: [Category, Location, Item, Tag],
synchronize: true,
}),
ItemsModule,
],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
await app.init();
});
afterAll(async () => {
await app.close();
});
it('should create, query, update and delete item successfully', async () => {
const agent = request.agent(app.getHttpServer());
const createResponse = await agent
.post('/items')
.send({
categoryId: 'uuid-cat',
name: '有机牛奶',
unit: '瓶',
})
.expect(201);
const detailResponse = await agent
.get(`/items/${createResponse.body.data.id}`)
.expect(200);
expect(detailResponse.body.data.name).toBe('有机牛奶');
});
});**关键点:**
- 使用 `TypeOrmModule.forRoot` + `sqlite :memory:`,每个测试运行时都构造独立数据库。
- 全局挂载 `ValidationPipe`、`HttpExceptionFilter`、`TransformInterceptor`,确保与实际运行环境一致。
- 使用 `request.agent` 模拟 HTTP 请求。
6. 集成测试思路(示意)
介于单测与 e2e 之间:通常会启动部分模块(含真实 Repository),但不通过 HTTP。
示例(缩写):
describe('PurchaseRecordsService', () => {
let service: PurchaseRecordsService;
let dataSource: DataSource;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({ type: 'sqlite', database: ':memory:', entities: [...] }),
TypeOrmModule.forFeature([...实体]),
],
providers: [PurchaseRecordsService, StockService],
}).compile();
dataSource = moduleRef.get(DataSource);
service = moduleRef.get(PurchaseRecordsService);
});
it('should confirm draft and create stock when missing', async () => {
// 使用真实 Repository + Service 验证事务逻辑
});
});
7. 测试约定与最佳实践
-
命名约定
- 单元测试:
*.spec.ts存放在src/同目录。 - E2E 测试:
*.e2e-spec.ts存放在test/目录。
- 单元测试:
-
快速执行
npm run test(单元测试)npm run test:e2e(端到端)
-
Mock vs 实际依赖
- Service 层优先 Mock Repository,确保测试聚焦逻辑。
- 涉及多模块协作、事务时考虑集成测试。
-
保持一致的测试环境
- e2e 需使用和生产一致的中间件(全局 Filter、Interceptor)。
- 数据库连接建议使用内存库或独立实例,避免影响真实数据。
-
测试数据准备
- 单测用 mock 数据;
- e2e 集成通过 Repository/HTTP 插入数据;
- 注意测试间互不影响(beforeEach reset,afterEach 清理)。
-
断言尽量明确
- 检查返回值、异常、调用次数(
expect(mock).toHaveBeenCalledWith(...))。
- 检查返回值、异常、调用次数(
8. 与内建中间件/拦截器结合
测试时需要考虑全局中间件的影响:
app.useGlobalPipes(new ValidationPipe({ ... }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());这样 e2e 测试得到的响应结构与生产一致(`{ success, data, message, timestamp }`)。
9. 常见坑与排查
| 问题 | 排查建议 |
|---|---|
TypeError: Class constructor ... cannot be invoked without 'new' | 未使用 Test.createTestingModule,或模块装饰器缺失 @Module |
No repository for "Entity" was found. | 没有在测试模块中引入 TypeOrmModule.forFeature([Entity]) |
| SQLite e2e 运行异常 | 包版本问题或未正确关闭 app.close() |
| 测试之间互相污染 | 使用 beforeEach 重置 mocks 或重新构建模块 |
| Mock QueryBuilder 逻辑复杂 | 单独封装 createQueryBuilderMock,并写好 returnThis |
| 端口占用(EADDRINUSE) | 旧实例未关闭,确保测试结束 await app.close() |
10. 示例命令集
单元测试
npm run test
单元测试并 watch
npm run test:watch
生成覆盖率报告
npm run test:cov
端到端测试
npm run test:e2e---
11. 总结
- 先单测、后集成、再 e2e:逐层构建,定位问题更快。
- Mock 合理使用:Service 单测 mock Repository,避免连接真实 DB。
- 内存数据库(SQLite)适合 e2e:快速、隔离、不依赖外部服务。
- 借助 Nest 提供的 TestingModule:模拟真实模块结构,少走弯路。
- 测试即文档:好的测试用例能告诉团队“该功能应该怎样运行”。
掌握测试后,你的 Nest 项目就拥有了“自我保护力”——重构、扩展、上线都更安心。希望这篇文章对你有帮助,欢迎把你的实践心得也分享出来,一起交流!🎯