使用 Jest 和 Supertest 编写 NestJS 单元测试与端到端测试

作者:服务小站时间:2026-10-30 19:14:42浏览:248

深入讲解如何在 NestJS 框架中使用 Jest 和 Supertest 编写高质量的单元测试与端到端(E2E)测试。涵盖依赖注入 Mock 技巧、Service 与 Controller 测试、HTTP 全链路断言、生命周期管理与排坑指南。

在构建企业级 Node.js 服务端应用时,自动化测试是保障代码健壮性与可维护性的关键防线。NestJS 官方脚手架开箱集成了 Jest 测试框架,并提供了专用的测试辅助工具包 @nestjs/testing 与 HTTP 仿真库 Supertest。通过单元测试(Unit Test),我们可以在微秒级时间内验证独立类与方法的业务分支;通过端到端测试(E2E Test),我们能够完整模拟真实客户端发起的 HTTP 请求,检验守卫、管道、拦截器与控制器的集成协作。本文将通过具体的项目实例,完整演示两类测试的设计规范、Mock 替换策略与最佳实践。

使用 Jest 和 Supertest 编写 NestJS 单元测试与端到端测试
图 0:NestJS 测试生态体系与分层定位

一、核心认知:NestJS 测试体系与分层边界

编写测试前,必须清晰界定单元测试与端到端测试的不同定位与执行边界。NestJS 的架构核心在于控制反转(IoC)容器与模块化机制,这为我们提供了极具灵活性的测试切入点:

  • 单元测试(Unit Test):聚焦于单一模块内部的类(如 Service、Pipe、Guard 或 Controller)。单元测试必须切断一切真实的外部副作用,如数据库读写、第三方 HTTP 调用及文件系统操作,利用 Jest 的虚拟桩(Mock/Stub)替代真实依赖,从而实现极快的执行速度与精确的问题定位。
  • 端到端测试(E2E Test):聚焦于系统的黑盒交互与完整协议链路。借助 Supertest 驱动,测试用例直接向底层 HTTP 监听器(如 Express 或 Fastify 实例)派发请求,验证从路由匹配、全局校验管道(ValidationPipe)、身份鉴权(Guards)直至响应序列化的完整流程。
使用 Jest 和 Supertest 编写 NestJS 单元测试与端到端测试
图 1:NestJS 单元测试与端到端测试架构及边界对比

二、使用 Jest 编写单元测试

单元测试的编写遵循“准备(Arrange)、执行(Act)、断言(Assert)”原则。在 NestJS 中,我们利用 @nestjs/testing 导出的 Test.createTestingModule 动态构建微型 IoC 容器,把非测试目标依赖全部替换为 Mock 桩。

1. 业务场景定义

假设我们有一个用户模块,包含 UsersService 与 UsersController。Service 依赖 TypeORM 的用户仓储(Repository)查询与创建用户:

src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  async findById(id: number): Promise<User> {
    const user = await this.userRepository.findOne({ where: { id } });
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    return user;
  }

  async create(dto: CreateUserDto): Promise<User> {
    const newUser = this.userRepository.create(dto);
    return await this.userRepository.save(newUser);
  }
}
使用 Jest 和 Supertest 编写 NestJS 单元测试与端到端测试
图 2:NestJS 单元测试标准执行四步法

2. 编写 Service 单元测试与依赖 Mock

在测试 UsersService 时,我们不需要真实的数据库连接。利用 getRepositoryToken(User) 作为 Injection Token,将仓储替换为一个具有 findOne、create 和 save 模拟函数的对象:

src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';

describe('UsersService', () => {
  let service: UsersService;

  // 1. 声明 Mock 依赖仓储
  const mockUserRepository = {
    findOne: jest.fn(),
    create: jest.fn(),
    save: jest.fn(),
  };

  beforeEach(async () => {
    // 重置所有 Mock 函数的调用状态与返回值
    jest.clearAllMocks();

    // 2. 编译轻量级测试容器
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: mockUserRepository,
        },
      ],
    }).compile();

    // 3. 提取被测服务实例
    service = module.get<UsersService>(UsersService);
  });

  describe('findById', () => {
    it('当用户存在时,应成功返回用户实体', async () => {
      const expectedUser = { id: 1, name: '张三', email: 'zhangsan@example.com' };
      mockUserRepository.findOne.mockResolvedValue(expectedUser);

      // 4. 执行并断言
      const result = await service.findById(1);

      expect(result).toEqual(expectedUser);
      expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
      expect(mockUserRepository.findOne).toHaveBeenCalledTimes(1);
    });

    it('当用户不存在时,应抛出 NotFoundException 异常', async () => {
      mockUserRepository.findOne.mockResolvedValue(null);

      // 断言异步异常抛出
      await expect(service.findById(999)).rejects.toThrow(NotFoundException);
      expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { id: 999 } });
    });
  });

  describe('create', () => {
    it('应正确调用仓储方法并返回新建用户', async () => {
      const dto = { name: '李四', email: 'lisi@example.com' };
      const createdEntity = { id: 2, ...dto };

      mockUserRepository.create.mockReturnValue(createdEntity);
      mockUserRepository.save.mockResolvedValue(createdEntity);

      const result = await service.create(dto);

      expect(mockUserRepository.create).toHaveBeenCalledWith(dto);
      expect(mockUserRepository.save).toHaveBeenCalledWith(createdEntity);
      expect(result).toEqual(createdEntity);
    });
  });
});

关键点说明:每次执行测试用例前,必须在 beforeEach 中调用 jest.clearAllMocks()。否则前一个测试用例对 Mock 函数的调用记录(如调用次数、调用参数)会污染后续用例的断言结果。

3. 编写 Controller 单元测试

Controller 的单元测试用于检验路由方法是否正确调用了 Service,并如实转发返回值。此时测试目标是 Controller,被 Mock 的则是 Service 接口:

src/users/users.controller.spec.ts
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 mockUsersService = {
    findById: jest.fn(),
    create: jest.fn(),
  };

  beforeEach(async () => {
    jest.clearAllMocks();

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: mockUsersService,
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  it('GET /users/:id 应当调用 UsersService.findById', async () => {
    const mockUser = { id: 1, name: '王五' };
    mockUsersService.findById.mockResolvedValue(mockUser);

    const result = await controller.findOne('1');

    expect(service.findById).toHaveBeenCalledWith(1);
    expect(result).toEqual(mockUser);
  });
});

三、使用 Supertest 编写端到端(E2E)测试

单元测试只能验证代码逻辑分支,无法验证“HTTP 路径是否拼错”、“DTO 校验是否生效”、“JWT 鉴权中间件是否放行”等系统级集成问题。NestJS 在 test/ 目录下独立配置了端到端测试工程,通过 Supertest 发起仿真网络调用。

使用 Jest 和 Supertest 编写 NestJS 单元测试与端到端测试
图 3:Supertest 端到端测试生命周期与 HTTP 执行闭环

1. E2E 测试环境配置与生命周期

端到端测试的核心在于启动一个完整的 INestApplication 实例。必须注意,在生产环境 main.ts 中配置的全局管道(如 ValidationPipe)、异常过滤器等,在 E2E 测试中不会自动继承,必须显式在测试容器初始化时挂载:

test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('UsersController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    // 1. 加载主应用模块或测试专用模块
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();

    // 2. 关键步骤:显式注入全局校验管道以保持环境一致性
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );

    // 3. 初始化 NestJS 应用及其底层 HTTP 适配器
    await app.init();
  });

  afterAll(async () => {
    // 4. 关键步骤:显式释放底层 HTTP 监听器与数据库连接池
    await app.close();
  });

  describe('/users (POST)', () => {
    it('当入参完整合法时,应成功创建用户并返回 HTTP 201', () => {
      const payload = {
        name: '赵六',
        email: 'zhaoliu@example.com',
      };

      return request(app.getHttpServer())
        .post('/users')
        .send(payload)
        .expect(201)
        .expect((res) => {
          expect(res.body).toHaveProperty('id');
          expect(res.body.name).toBe(payload.name);
          expect(res.body.email).toBe(payload.email);
        });
    });

    it('当邮箱格式非法时,应被 ValidationPipe 拦截并返回 HTTP 400', () => {
      const invalidPayload = {
        name: '赵六',
        email: 'not-an-email',
      };

      return request(app.getHttpServer())
        .post('/users')
        .send(invalidPayload)
        .expect(400)
        .expect((res) => {
          expect(res.body.message).toEqual(
            expect.arrayContaining(['email must be an email']),
          );
        });
    });
  });

  describe('/users/:id (GET)', () => {
    it('查询不存在的 ID 时,应触发 NotFoundException 并返回 HTTP 404', () => {
      return request(app.getHttpServer())
        .get('/users/99999')
        .expect(404)
        .expect((res) => {
          expect(res.body.message).toContain('not found');
        });
    });
  });
});

防坑提醒:在 afterAll 中必须执行 await app.close()。若遗漏此步,NestJS 内部的 HTTP 监听套接字或数据库连接池会持续驻留内存,导致 Jest 在用例全部执行完成后报错:Jest has detected the following 1 open handle potentially keeping Jest from exiting。

四、测试执行命令与输出解析

在项目的 package.json 中,默认定义了针对单元测试与 E2E 测试的执行脚本。两者的 Jest 配置文件与匹配规则各自独立:

package.json 脚本片段
"scripts": {
  "test": "jest",
  "test:watch": "jest --watch",
  "test:cov": "jest --coverage",
  "test:e2e": "jest --config ./test/jest-e2e.json --detectOpenHandles"
}

执行单元测试命令:

npm run test

示例输出:

 PASS  src/users/users.service.spec.ts
 PASS  src/users/users.controller.spec.ts

Test Suites: 2 passed, 2 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        1.242 s
Ran all test suites.

执行端到端测试命令:

npm run test:e2e

示例输出:

 PASS  test/users.e2e-spec.ts
  UsersController (e2e)
    /users (POST)
      ✓ 当入参完整合法时,应成功创建用户并返回 HTTP 201 (182 ms)
      ✓ 当邮箱格式非法时,应被 ValidationPipe 拦截并返回 HTTP 400 (45 ms)
    /users/:id (GET)
      ✓ 查询不存在的 ID 时,应触发 NotFoundException 并返回 HTTP 404 (38 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.854 s
使用 Jest 和 Supertest 编写 NestJS 单元测试与端到端测试
图 4:NestJS 自动化测试分层金字塔与落地决策矩阵

五、工程实践总结与关键注意事项

为了让测试既能有效拦截线上缺陷,又不会因为维护成本过高而成为团队负担,建议遵循以下工程规范:

  1. 依赖替换颗粒度把握:编写单元测试时,只对被测类的直接协作对象进行 Mock。切勿通过 jest.spyOn 侵入私有方法或过度破坏面向对象封装。如果一个 Service 依赖超过 5 个外部类,往往意味着单一职责被打破,应优先考虑代码重构而非堆砌复杂的 Mock 结构。
  2. E2E 测试数据库隔离:端到端测试中,建议为测试环境配置专用的测试数据库(例如 Docker 容器中的 PostgreSQL 实例,或内存型 SQLite 数据库)。在测试用例集执行完毕后,通过数据库事务回滚或数据表清空钩子,避免持久化脏数据影响后续用例。
  3. 守卫(Guards)与全局配置对齐:如果生产环境依赖了全局路由前缀(app.setGlobalPrefix('api'))或身份验证守卫(如 JWT Guard),在 E2E 容器中需通过 overrideGuard() 或加载对应环境配置,保证测试流量能真实模拟生产通信规范。
  4. 持续集成(CI)流水线设计:在 CI 流水线中,建议先并行执行轻量的 npm run test(单元测试),在全部通过后再串行触发 npm run test:e2e。这样可以在代码存在基础逻辑错误时秒级阻断构建,大幅节省计算资源与等待时间。
作者最新文章
相关文章
更多