J10 · 单元测试、集成测试与端到端测试
Jest 同时扮演 JUnit、Mockito 和 AssertJ 的角色。@nestjs/testing 提供相当于 Spring 测试上下文的东西:一个为测试构建的模块,你可以在里面 替换任意 provider。借助 supertest,你可以针对真实的 PostgreSQL 测试完整的 HTTP 应用(guard、管道、过滤器)。TaskFlow 中有 27 个单元测试、 8 个端到端测试,以及一个会让 CI 失败的覆盖率阈值。
目标
- 用
describe、it、beforeEach、jest.fn()和jest.mock()编写 Jest 测试。 - 用
Test.createTestingModule和 mock 的 provider(useValue)构建测试模块。 - 用
getRepositoryToken(Entity)mock 一个 TypeORM repository。 - 用 supertest 在专用的 PostgreSQL 数据库(Compose 服务或 Testcontainers)上运行端到端测试。
- 测试
ProjectsService和TasksService的业务规则,测试 register → login → 项目 → 任务 → 状态这条流程,并用npm run test:cov强制执行覆盖率阈值。
Spring → NestJS
| Spring | NestJS | 要点 |
|---|---|---|
JUnit 5:@Test、@Nested、@BeforeEach | it()、describe()、beforeEach() | 是函数,不是注解 |
AssertJ assertThat(x).isEqualTo(y) | expect(x).toEqual(y) | Jest 自带 |
Mockito mock()、when(...).thenReturn(...) | jest.fn()、.mockResolvedValue(...) | 返回 Promise 的方法用 mockResolvedValue |
verify(mock).publishEvent(event) | expect(fn).toHaveBeenCalledWith(event) | 参数做递归相等比较 |
ArgumentCaptor | fn.mock.calls[0] | 所有调用都会被记录 |
| Mockito 的类型化 mock | jest.Mocked<T> | 只在编译期检查 |
@MockBean | { provide: X, useValue: mock } 或 .overrideProvider(X).useValue(mock) | 按 token 替换 |
@SpringBootTest + MockMvc | Test.createTestingModule({ imports: [AppModule] }) + supertest | 应用启动时不占用固定端口 |
@DataJpaTest | 使用真实数据库的集成测试(Testcontainers) | TaskFlow 使用一个专用的 Compose 服务 |
JaCoCo + jacoco:check | coverageThreshold + npm run test:cov | 低于阈值时构建失败 |
原理
Jest:describe、it、jest.fn()、jest.mock()、beforeEach
Jest 同时是测试运行器、断言库和 mock 工具。describe() 把测试分组,it() 声明一个测试,beforeEach() / afterEach() / beforeAll() / afterAll() 包住执行过程。同一个文件里的测试按顺序执行;不同文件则在多个 worker 中并行运行。ts-jest 即时编译 TypeScript。
jest.fn() 创建一个会记录调用(fn.mock.calls)并返回你配置的值的函数:
| Mockito | Jest |
|---|---|
when(repo.findById(id)).thenReturn(task) | repository.findOneBy.mockResolvedValue(task) |
thenThrow(new NotFoundException()) | mockRejectedValue(new NotFoundException()) |
thenAnswer(inv -> ...) | mockImplementation((arg) => ...) |
verify(repo, never()).save(any()) | expect(repository.save).not.toHaveBeenCalled() |
@Spy | jest.spyOn(对象, '方法') |
jest.Mocked<T> 为 mock 对象提供类型:jest.Mocked<Pick<EventEmitter2, 'emit'>> 只接受 emit,events.emit.mockReturnValue(...) 也会被 编译器检查。
jest.mock('module', 工厂函数) 在整个测试文件中替换一个完整的模块(最接近的对应物是对某个库的函数使用 Mockito.mockStatic())。编译时这个调用 会被提升到 import 之上:工厂函数在文件其余部分之前执行,不能读取文件里的变量。在工厂函数里创建 jest.fn(),再用 jest.mocked() 取回。
// TaskFlow 之外的示例:在 auth.service.spec.ts 中替换 argon2
import * as argon2 from 'argon2';
jest.mock('argon2', () => ({
hash: jest.fn().mockResolvedValue('$argon2id$fake'),
verify: jest.fn().mockResolvedValue(true),
}));
it('refuses a wrong password', async () => {
jest.mocked(argon2.verify).mockResolvedValueOnce(false);
// ... 调用 service.login(),预期抛出 UnauthorizedException
});在 describe 层级创建一次的 mock,会在测试之间保留调用历史:UsersService 的测试在 beforeEach 中调用 jest.clearAllMocks(),TasksService 的测试则在每个测试里重新创建 mock。另外,异步断言必须被等待:不加 await 的话,expect(promise).rejects... 在测试结束前不会被检查,测试无论如何 都会通过。
NestJS 12 的 starter 在 CommonJS + Jest 变体中(nest new 的另一个选项是 ESM + Vitest,J1), 用 node --experimental-vm-modules ./node_modules/jest/bin/jest.js 启动 Jest(test、test:cov、test:e2e 脚本)。这个参数启用 Jest 的 ESM 支持;Node 可能会显示 ExperimentalWarning: VM Modules is an experimental feature…,不影响结果。TaskFlow 仍是由 ts-jest 编译的 CommonJS 项目, jest.mock() 照常工作。用 npm test 而不是 npx jest,以保持相同的选项。
Test.createTestingModule 与 mock 的 provider
Test.createTestingModule({ providers: [...] }) 在内存中构建一个 Nest 模块,里面只有你声明的东西。.compile() 像启动时一样解析依赖注入图, 然后 moduleRef.get(Service) 返回被测实例。每个依赖都按 token 替换(provider):
{ provide: ProjectsService, useValue: mock }:一个 mock 对象;{ provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }:一个测试用实现(fake);{ provide: CLOCK, useValue: () => now }:一个固定值。
缺少依赖时,compile() 会失败,报 “Nest can't resolve dependencies of the TasksService…”,并指出找不到的参数。对于导入 AppModule 的测试, 在 .compile() 之前调用 .overrideProvider(X).useValue(mock),作用相当于 @MockBean。
类比失效的地方:没有 classpath 扫描。测试模块只包含你列出的东西,因此又快又明确。guard、过滤器或拦截器甚至可以不用 Nest,直接 new 出来测试 (guards.spec.ts、all-exceptions.filter.spec.ts)。这种情况下,如果代码会读取元数据,要自己加载 reflect-metadata (陷阱)。
用 supertest 和专用数据库做端到端测试
端到端测试会启动真实的应用,并向它发送 HTTP 请求:
Test.createTestingModule({ imports: [AppModule] }).compile()构建完整的依赖图;moduleRef.createNestApplication()再加app.init()初始化应用,不打开端口;request(app.getHttpServer())(supertest)打开一个临时端口并发送请求;afterAll(() => app.close())关闭 PostgreSQL 连接池、定时任务等。不这样做的话,Jest 不会退出: “Jest did not exit one second after the test run has completed”。
会生效的:AppModule 中声明的一切,包括 APP_PIPE、APP_GUARD、APP_INTERCEPTOR 和 APP_FILTER(全局过滤器)。 不会生效的:main.ts。所以有了 setupApp(),由 main.ts 和端到端测试共同调用(版本、helmet、CORS、logger)。
数据库:未定义 NODE_ENV 时 Jest 把它设为 test,于是 ConfigModule 加载 .env.test(profile),它指向 Compose 中的 db-test 服务(端口 5433,数据放在 tmpfs 中)。test:e2e 脚本带有 --runInBand:端到端测试文件依次执行,因为它们共用同一个数据库。
测试用 PostgreSQL 数据库:Compose 还是 Testcontainers
有两种方式获得一次性数据库:
Compose 服务 db-test(TaskFlow) | Testcontainers | |
|---|---|---|
| 启动 | 测试前执行 docker compose up -d db-test | 测试通过 Docker API 启动自己的容器 |
| 端口 | 固定(5433) | 随机,在测试中读取 |
| 隔离 | 共享一个数据库,由测试清空 | 每个测试套件一个容器 |
| GitLab CI | job 中的 postgres:16-alpine 服务 | 需要在 job 中使用 Docker(Docker-in-Docker) |
用 @testcontainers/postgresql(npm i -D @testcontainers/postgresql,需要 Docker)实现 @DataJpaTest 的对应物(真实数据库,不启动整个应用):
// TaskFlow 之外的示例:test/tasks-table.e2e-spec.ts
// (命名为 *.e2e-spec.ts,以便被 test/jest-e2e.json 收录)
import 'reflect-metadata';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { DataSource, QueryFailedError } from 'typeorm';
import { postgresOptions } from '../src/database/postgres-options';
import { Task } from '../src/tasks/entities/task.entity';
describe('tasks table (real PostgreSQL)', () => {
let container: StartedPostgreSqlContainer;
let dataSource: DataSource;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start();
dataSource = new DataSource({
...postgresOptions({
host: container.getHost(),
port: container.getPort(), // 宿主机端口,随机分配
username: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
}),
entities: [join(__dirname, '..', 'src', '**', '*.entity.ts')],
});
await dataSource.initialize();
await dataSource.runMigrations(); // TaskFlow 纳入版本控制的迁移
}, 60_000); // 第一次下载镜像可能比较慢
afterAll(async () => {
await dataSource?.destroy();
await container?.stop();
});
it('rejects a task whose project does not exist', async () => {
await expect(
dataSource.getRepository(Task).insert({ title: 'Orphan', projectId: randomUUID() }),
).rejects.toBeInstanceOf(QueryFailedError);
});
});这个测试验证了 mock 无法验证的东西:迁移、外键、实际生成的 SQL。
用 getRepositoryToken mock repository
@InjectRepository(Task) 是 @Inject(getRepositoryToken(Task)) 的简写:repository 不是你写的类,它的 token 是根据实体计算出来的 (repository)。在测试中,用一个只包含被调用方法的对象提供这个 token:
{ provide: getRepositoryToken(Task), useValue: { findOneBy: jest.fn(), save: jest.fn(), update: jest.fn() } }如果 service 调用了 mock 中没有的方法,测试会失败,报 TypeError: this.tasks.find is not a function:把这个方法加到 mock 里。
选择合适的层级:
| 手段 | 能证明什么 | TaskFlow 示例 |
|---|---|---|
Mock(jest.fn()) | service 的决策和它发出的调用 | TasksService:事件的发出、访问检查 |
| Fake(内存实现) | 在接近真实的状态下验证业务规则 | 使用 InMemoryProjectsStore 的 ProjectsService |
| 真实数据库 | 查询、迁移、约束 | 在 db-test 上的端到端测试 |
repository 的 mock 证明不了 LessThan(today) 会生成正确的 SQL:这是真实数据库上的测试要做的事。
TaskFlow 案例
ProjectsService 和 TasksService 的单元测试
ProjectsService 用一个 fake 测试:PROJECTS_STORE token 背后是真正的 InMemoryProjectsStore。这就是 J3 引入的抽象带来的好处:不需要数据库,也不需要配置任何 mock。
describe('ProjectsService', () => {
let service: ProjectsService;
beforeEach(async () => {
// Like a @SpringBootTest slice: only the providers this test needs, with
// the real in-memory store standing in for the database.
const moduleRef = await Test.createTestingModule({
providers: [ProjectsService, { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }],
}).compile();
service = moduleRef.get(ProjectsService);
});
it('makes the creator a member who can read the project', async () => {
const project = await service.create({ name: 'Website' }, alice);
await expect(service.findOne(project.id, alice)).resolves.toMatchObject({ name: 'Website' });
});
it('answers 404, not 403, to a user outside the project', async () => {
const project = await service.create({ name: 'Website' }, alice);
await expect(service.findOne(project.id, bob)).rejects.toBeInstanceOf(NotFoundException);
});六个测试覆盖了业务规则:创建者成为成员并能读取项目;非成员收到 404 而不是 403;成员只能列出自己的项目,管理员能看到全部;过滤和分页;归档后软删除; 被软删除的项目对原成员和管理员一样关闭,任务也包括在内(assertAccess() 以 NotFoundException 拒绝,J8)。
TasksService 依赖一个 repository、ProjectsService、EventEmitter2 和时钟:全部被 mock。
describe('TasksService', () => {
let service: TasksService;
// Hand-written mocks: only the methods the service calls, each a jest.fn()
// (Mockito's mock() + when()). jest.Mocked<T> keeps them typed.
let repository: { findOneBy: jest.Mock; save: jest.Mock; update: jest.Mock };
let events: jest.Mocked<Pick<EventEmitter2, 'emit'>>;
let projects: jest.Mocked<Pick<ProjectsService, 'assertAccess'>>;
beforeEach(async () => {
repository = { findOneBy: jest.fn(), save: jest.fn((task: Task) => Promise.resolve(task)), update: jest.fn() };
events = { emit: jest.fn() };
projects = { assertAccess: jest.fn().mockResolvedValue(undefined) };
const moduleRef = await Test.createTestingModule({
providers: [
TasksService,
// The token @InjectRepository(Task) asks for.
{ provide: getRepositoryToken(Task), useValue: repository },
{ provide: EventEmitter2, useValue: events },
{ provide: ProjectsService, useValue: projects },
{ provide: CLOCK, useValue: () => now },
],
}).compile();
service = moduleRef.get(TasksService);
});it('saves the new status, then emits task.status.changed', async () => {
repository.findOneBy.mockResolvedValue(Object.assign(new Task(), { id: 't1', projectId: 'p1', status: TaskStatus.Todo }));
const task = await service.updateStatus('t1', TaskStatus.Done, user);
expect(task.status).toBe(TaskStatus.Done);
expect(repository.save).toHaveBeenCalledTimes(1);
expect(events.emit).toHaveBeenCalledWith(
TASK_STATUS_CHANGED,
new TaskStatusChangedEvent('t1', 'p1', TaskStatus.Todo, TaskStatus.Done, 'u1', now),
);
});TasksService 的其他测试验证了:
- 状态不变 → 既不调用
save()也不调用emit(); - 访问被拒绝(
assertAccess以NotFoundException拒绝)→ 错误向上抛出,什么都不保存; - 未知任务 →
NotFoundException; markOverdue()→ 只调用一次update(),使用固定时钟的日期。测试从repository.update.mock.calls[0]读取参数,就像ArgumentCaptor;- 夏季凌晨 1 点执行
markOverdue()(时钟为前一天 UTC 23 点)→ 采用的是巴黎的日期(OVERDUE 定时任务)。 这个测试创建了自己的模块,以便设置另一个时钟。
npm test 执行 27 个测试,分布在 8 个 *.spec.ts 文件中,和代码放在一起:service(auth、users、projects、tasks)、RolesGuard、 AllExceptionsFilter、LoggingInterceptor 以及环境变量校验。最后这个测试不启动 Nest,因此以 import 'reflect-metadata' 开头。
端到端流程:register → login → 项目 → 任务 → 状态
前提:测试数据库,以及一个本地的 .env.test 文件(不纳入版本控制,从 .env.example 复制)。
docker compose up -d db-test
cp .env.example .env.test # 然后设置 NODE_ENV=test、DB_PORT=5433 和 DB_NAME=taskflow_test
npm run test:e2e准备工作:
describe('TaskFlow (e2e)', () => {
let app: INestApplication<App>;
let http: ReturnType<typeof request>;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
setupApp(app); // the same versioning, helmet and logger as main.ts
await app.init();
const dataSource = app.get(DataSource);
await dataSource.runMigrations();
await dataSource.query('TRUNCATE users, projects, tasks, project_members CASCADE');
http = request(app.getHttpServer());
});
afterAll(async () => {
await app.close();
});setupApp(app)应用和main.ts相同的 HTTP 设置。- 先执行纳入版本控制的迁移,再清空数据表:每次运行都从干净的数据库开始(迁移)。
绝不要在真实数据库上运行
测试会执行 TRUNCATE users, projects, tasks, project_members CASCADE。如果 .env.test 指向你的开发数据库,里面的数据会被清空。使用专用数据库 (db-test,端口 5433)。
学习计划要求的流程:
it('register -> login -> project -> task -> status change', async () => {
const register = await http.post('/v1/auth/register').send({ email: 'alice@example.com', password }).expect(201);
expect(register.body.passwordHash).toBeUndefined(); // @Exclude + ClassSerializerInterceptor
expect(register.body.role).toBe(Role.Member);
const login = await http.post('/v1/auth/login').send({ email: 'alice@example.com', password }).expect(200);
const auth = { Authorization: `Bearer ${login.body.accessToken}` };
const project = await http.post('/v1/projects').set(auth).send({ name: 'Website' }).expect(201);
const task = await http
.post(`/v1/projects/${project.body.id}/tasks`)
.set(auth)
.send({ title: 'Write the README', dueDate: '2026-11-24' })
.expect(201);
expect(task.body.status).toBe('TODO');
await http.patch(`/v1/tasks/${task.body.id}/status`).set(auth).send({ status: 'DONE' }).expect(200);
const done = await http.get(`/v1/projects/${project.body.id}/tasks?status=DONE`).set(auth).expect(200);
expect(done.body).toHaveLength(1);
});8 个端到端测试都在真实的 PostgreSQL 上通过。其他七个验证了:
| 测试 | 预期结果 |
|---|---|
| 不带 token 的请求 | 401 application/problem+json,带上发送的 x-correlation-id |
未知字段、name 为空、?page=0、?status=LATE、非 UUID 的 id | 400 |
其他成员的项目 / 以 MEMBER 身份访问 /v1/users | 404 / 403 |
| 同一邮箱注册两次 | 409 |
| 归档项目 | 它的任务从列表中消失 |
/health 和 /health/live | 200,数据库 up |
| 一分钟内第 6 次登录 | 429,code: "TOO_MANY_REQUESTS" |
最后一个测试依赖前面的测试:它们已经用掉了 /v1/auth/login 上 @Throttle 允许的每分钟 5 次登录(安全)。 它必须保持在文件的最后。
端到端测试的 Jest 配置:
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}覆盖率阈值
coverageThreshold: {
global: { branches: 60, functions: 70, lines: 70, statements: 70 },
},npm run test:cov 带 --coverage 运行单元测试,并把结果和阈值比较。低于阈值时,Jest 以错误退出,CI 的 unit-tests job 失败 (CI)。TaskFlow 的结果:行 86.3%,语句 87.7%,函数 75%,分支 71.4%。HTML 报告在 coverage/lcov-report/index.html。
统计只针对单元测试(端到端测试不计入),并且只针对 jest.config.ts 中 collectCoverageFrom 选中的文件,共 7 个:四个 service、auth/guards.ts (JwtAuthGuard 和 RolesGuard)、全局过滤器和日志拦截器。报告显示了单元测试没有覆盖到的部分:JwtAuthGuard、TasksService.create() 和 list(), 只有端到端测试会执行它们。没有被任何模式选中的文件根本不会出现:每次修改 collectCoverageFrom 后,都要重新检查报告里的文件列表。
阈值是防止退化的底线,不是目标:100% 的行都被没有有效断言的测试覆盖,什么也证明不了。
场景速查
- 要在没有数据库的情况下测试 service →
Test.createTestingModule和useValue(测试模块)。 - service 注入了
@InjectRepository(Task)→{ provide: getRepositoryToken(Task), useValue: mock }(repository)。 - 要验证事件被发出 → mock
EventEmitter2,用toHaveBeenCalledWith(单元测试)。 - 结果取决于当天日期 → 注入一个时钟(
CLOCKtoken),在测试中把它固定(token)。 - 要替换整个 npm 模块(argon2、某个 SDK) →
jest.mock()(Jest)。 - 想把 guard、管道和过滤器一起测试 → 基于
AppModule+setupApp()+ supertest 的端到端测试(端到端)。 - 需要一个一次性的 PostgreSQL 数据库 → Compose 的
db-test服务或 Testcontainers(测试数据库)。 - 覆盖率低于阈值时 CI 要失败 →
coverageThreshold+npm run test:cov(覆盖率)。
陷阱
“Reflect.getMetadata is not a function”
Nest 在启动时加载 reflect-metadata。不启动 Nest 的测试(环境变量校验、plainToInstance)必须以 import 'reflect-metadata' 开头 (陷阱)。
端到端测试中途出现 429
throttler 也会统计测试发出的请求:端到端测试套件自己就达到了每分钟 5 次登录的上限。测试 429 的用例放在最后;在它之前再加登录,会让 429 提前出现, 破坏后面的测试。
expect(...).rejects前忘了await。 测试在检查之前就结束了,永远通过。写成await expect(promise).rejects.toBeInstanceOf(...)。- 测试之间共享 mock。 在
describe层级创建的jest.fn()会保留调用记录:在beforeEach中调用jest.clearAllMocks(),或者重新创建 mock。 - mock 中缺少方法。
TypeError: ... is not a function:service 调用了 mock 没有声明的方法。 - 在
main.ts中调用app.useGlobalPipes()。 端到端测试不执行main.ts:把全局组件声明为APP_*provider,其余的放到setupApp()中共享。 - 忘了
app.close()。 连接池和定时任务仍在运行,Jest 不会退出。 - 端到端测试文件在同一个数据库上并行运行。 保留
--runInBand(test:e2e脚本)。 - 覆盖率模式什么都没匹配到。 文件从报告中消失,没有任何警告(覆盖率)。
练习
- [ ]
ProjectsService的单元测试:创建者成为成员、非成员收到 404、管理员视图、过滤和分页、归档和软删除、已删除的项目对原成员关闭。 - [ ]
TasksService的单元测试:save()之后发出事件、状态不变时什么都不做、写入前检查访问权限、未知任务、用固定时钟测试markOverdue(), 包括夏季凌晨 1 点的情况。 - [ ] 用
getRepositoryToken(Task)mock repository,然后从 mock 中删掉一个方法,阅读得到的错误。 - [ ] 在
auth.service.spec.ts中用jest.mock()替换argon2,并与真实调用比较。 - [ ] 在
db-test上编写端到端测试:register → login → 创建项目 → 创建任务 → 修改状态。 - [ ] 在
jest.config.ts中加入coverageThreshold,运行npm run test:cov,然后把某个阈值调到结果之上(例如branches: 80),看它失败。 - [ ] 编写
JwtAuthGuard的单元测试(@Public()路由不带 token 也能通过),观察报告中guards.ts的覆盖率上升。 - [ ] 从
env.validation.spec.ts中删除import 'reflect-metadata',观察报错。 - [ ] 可选:编写上面的 Testcontainers 测试。
- [ ] 在
LEARNINGS.md里记下和 Mockito、@SpringBootTest不同的地方,然后git tag jour-10。