Skip to content

J10 · 单元测试、集成测试与端到端测试 ​

第 10 天 · 11 月 23 日周一 · 交付物:关键模块的覆盖率

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 ​

SpringNestJS要点
JUnit 5:@Test、@Nested、@BeforeEachit()、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)参数做递归相等比较
ArgumentCaptorfn.mock.calls[0]所有调用都会被记录
Mockito 的类型化 mockjest.Mocked<T>只在编译期检查
@MockBean{ provide: X, useValue: mock } 或 .overrideProvider(X).useValue(mock)按 token 替换
@SpringBootTest + MockMvcTest.createTestingModule({ imports: [AppModule] }) + supertest应用启动时不占用固定端口
@DataJpaTest使用真实数据库的集成测试(Testcontainers)TaskFlow 使用一个专用的 Compose 服务
JaCoCo + jacoco:checkcoverageThreshold + 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)并返回你配置的值的函数:

MockitoJest
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()
@Spyjest.spyOn(对象, '方法')

jest.Mocked<T> 为 mock 对象提供类型:jest.Mocked<Pick<EventEmitter2, 'emit'>> 只接受 emit,events.emit.mockReturnValue(...) 也会被 编译器检查。

jest.mock('module', 工厂函数) 在整个测试文件中替换一个完整的模块(最接近的对应物是对某个库的函数使用 Mockito.mockStatic())。编译时这个调用 会被提升到 import 之上:工厂函数在文件其余部分之前执行,不能读取文件里的变量。在工厂函数里创建 jest.fn(),再用 jest.mocked() 取回。

ts
// 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 请求:

  1. Test.createTestingModule({ imports: [AppModule] }).compile() 构建完整的依赖图;
  2. moduleRef.createNestApplication() 再加 app.init() 初始化应用,不打开端口;
  3. request(app.getHttpServer())(supertest)打开一个临时端口并发送请求;
  4. 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 CIjob 中的 postgres:16-alpine 服务需要在 job 中使用 Docker(Docker-in-Docker)

用 @testcontainers/postgresql(npm i -D @testcontainers/postgresql,需要 Docker)实现 @DataJpaTest 的对应物(真实数据库,不启动整个应用):

ts
// 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:

ts
{ 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。

ts
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。

ts
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);
  });
ts
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 复制)。

bash
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

准备工作:

ts
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)。

学习计划要求的流程:

ts
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 的 id400
其他成员的项目 / 以 MEMBER 身份访问 /v1/users404 / 403
同一邮箱注册两次409
归档项目它的任务从列表中消失
/health 和 /health/live200,数据库 up
一分钟内第 6 次登录429,code: "TOO_MANY_REQUESTS"

最后一个测试依赖前面的测试:它们已经用掉了 /v1/auth/login 上 @Throttle 允许的每分钟 5 次登录(安全)。 它必须保持在文件的最后。

端到端测试的 Jest 配置:

json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}

覆盖率阈值 ​

ts
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(单元测试)。
  • 结果取决于当天日期 → 注入一个时钟(CLOCK token),在测试中把它固定(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。

延伸阅读 ​