Skip to content

测试 (Testing)

好的,我们终于来到了软件开发中一个至关重要的环节:测试 (Testing)。一个没有经过测试的应用,就像一艘没有经过试航的船,你永远不知道它在真正的风浪中会表现如何。NestJS 作为一个专业的框架,内置了对测试的一流支持,让编写测试变得简单而高效。

为你的代码“体检”:NestJS 测试完全入门指南

想象一下你是一位汽车工程师,你刚刚设计了一台全新的引擎。

在把这台引擎装上汽车进行路测之前,你会先在实验台上对它进行各种独立的测试:

  • 单元测试 (Unit Testing):只测试引擎本身。你会给它接上燃料管和电线,启动它,然后检查它的转速、功率、油耗是否符合设计标准。你不会关心轮胎、变速箱或车身。
  • 端到端测试 (End-to-End, E2E Testing):把引擎装进完整的汽车里,然后让一位试车员把车开到真实的赛道上跑几圈。你测试的是整个系统——从踩下油门踏板,到引擎轰鸣,再到车轮飞转——这一整套流程是否顺畅。

在 NestJS 中,我们也会对应用进行这两种核心类型的测试,以确保代码的质量和可靠性。

1. 单元测试 (Unit Testing) - “在实验台上测试引擎”

核心思想隔离。单元测试的目标是孤立地测试一个独立的“单元”(通常是一个类,比如一个 ServiceController),而不受其依赖项的干扰。

如何实现隔离? 通过模拟 (Mocking)。如果我们的 CatsService 依赖于 DatabaseService,在测试 CatsService 时,我们不希望它真的去连接数据库。我们会创建一个假的 DatabaseService (一个 Mock),这个假的服务会按照我们的指令返回预设好的数据。

NestJS 与 Jest 测试框架深度集成,提供了强大的工具来简化这个过程。

实战:测试 CatsService

假设我们有这样一个 CatsService

src/cats/cats.service.ts

typescript
@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [
    { id: 1, name: 'Mittens', age: 5, breed: 'Persian' },
  ];

  findOne(id: number): Cat {
    const cat = this.cats.find((cat) => cat.id === id);
    if (!cat) {
      throw new NotFoundException(`Cat with ID ${id} not found`);
    }
    return cat;
  }
}

当你用 Nest CLI 创建这个服务时 (nest g s cats),它会自动生成一个测试文件骨架:

src/cats/cats.service.spec.ts

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { CatsService } from './cats.service';

// 1. 'describe' 块定义了一个测试套件,描述了我们要测试的对象
describe('CatsService', () => {
  let service: CatsService; // 声明一个变量来持有服务实例

  // 2. 'beforeEach' 会在每个 'it' 测试用例运行前执行
  beforeEach(async () => {
    // 3. Test.createTestingModule() 创建一个临时的测试模块
    const module: TestingModule = await Test.createTestingModule({
      providers: [CatsService], // 把我们要测试的服务放在 providers 里
    }).compile();

    // 4. 从测试模块中获取服务实例
    service = module.get<CatsService>(CatsService);
  });

  // 5. 'it' 定义了一个具体的测试用例,描述了期望的行为
  it('should be defined', () => {
    // 'expect' 是 Jest 的断言函数,'toBeDefined' 是一个匹配器
    expect(service).toBeDefined();
  });
});

Test.createTestingModule 是单元测试的核心。它会创建一个临时的、轻量级的 NestJS 应用上下文,只包含你明确指定的 providerscontrollers。这就像为你的引擎搭建一个临时的实验台。

现在,让我们添加更有意义的测试用例:

typescript
// ...接上面的代码

describe('findOne', () => {
  it('should return a cat object when a valid ID is passed', () => {
    const catId = 1;
    const expectedCat = { id: 1, name: 'Mittens', age: 5, breed: 'Persian' };

    // 调用方法并断言返回值是否和预期相符
    expect(service.findOne(catId)).toEqual(expectedCat);
  });

  it('should throw a NotFoundException when an invalid ID is passed', () => {
    const catId = 99;

    // 测试一个函数是否会抛出特定类型的异常
    // 必须把要执行的函数包在一个箭头函数里
    expect(() => service.findOne(catId)).toThrow(NotFoundException);
    expect(() => service.findOne(catId)).toThrow('Cat with ID 99 not found');
  });
});

如何运行测试? 在你的终端中运行:

bash
npm run test

Jest 会自动找到所有 .spec.ts 文件并执行它们。

单元测试与模拟 (Mocking)

现在来个更复杂的,如果 CatsService 依赖别的服务怎么办?

typescript
// cats.service.ts
@Injectable()
export class CatsService {
  // 它依赖一个虚构的 UsersService 来判断权限
  constructor(private readonly usersService: UsersService) {}

  async findOneForUser(catId: number, userId: string): Promise<Cat> {
    const hasPermission = await this.usersService.canUserAccessCat(
      userId,
      catId
    );
    if (!hasPermission) {
      throw new ForbiddenException();
    }
    // ...返回 cat 的逻辑
  }
}

在测试 CatsService 时,我们不关心 UsersService 的内部实现,我们只关心 CatsService 能否根据 UsersService返回值做出正确的反应。

src/cats/cats.service.spec.ts (带 Mock)

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { CatsService } from './cats.service';
import { UsersService } from '../users/users.service'; // 假设的依赖
import { ForbiddenException } from '@nestjs/common';

// 1. 创建一个模拟对象
const mockUsersService = {
  // 我们模拟 canUserAccessCat 方法
  canUserAccessCat: jest.fn(),
};

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        CatsService,
        {
          // 2. 当请求 UsersService 时,提供我们的模拟对象
          provide: UsersService,
          useValue: mockUsersService,
        },
      ],
    }).compile();

    service = module.get<CatsService>(CatsService);
  });

  it('should throw ForbiddenException if user does not have permission', async () => {
    // 3. 为这个测试用例设置模拟函数的返回值
    mockUsersService.canUserAccessCat.mockResolvedValue(false);

    // 断言一个异步函数会拒绝 (reject) 一个 Promise
    await expect(service.findOneForUser(1, 'user-b')).rejects.toThrow(ForbiddenException);
  });
});
```*   `jest.fn()`: Jest 提供的函数,可以创建一个可被追踪和配置的模拟函数。
*   `mockResolvedValue(false)`: 让这个模拟的异步函数返回一个解析为 `false` 的 Promise。
*   `useValue`: 使用我们之前学过的自定义提供者语法,将 `UsersService` 的令牌绑定到一个具体的模拟对象上。

### 2. 端到端测试 (E2E Testing) - “在赛道上测试整车”

**核心思想**:**集成**。E2E 测试不使用模拟对象(或很少使用)。它会启动一个**几乎完整**的 NestJS 应用实例,然后像一个真实的用户一样,通过 HTTP 请求来测试应用的行为,并检查 HTTP 响应是否符合预期。

**使用什么工具?** E2E 测试通常使用 [Supertest](https://github.com/visionmedia/supertest) 这个库,它能让你轻松地对 HTTP 应用进行编程化的请求。

Nest CLI 在创建项目时,会自动在 `test` 目录下生成一个 E2E 测试文件。

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

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

  beforeEach(async () => {
    // 1. 这里我们用了真实的应用主模块 AppModule!
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    // 2. 创建一个完整的 Nest 应用实例
    app = moduleFixture.createNestApplication();
    // 3. 初始化应用(执行生命周期钩子等)
    await app.init();
  });

  // 在所有测试后关闭应用
  afterAll(async () => {
    await app.close();
  });

  // 4. 测试 HTTP 端点
  it('/ (GET)', () => {
    return request(app.getHttpServer()) // 获取底层的 HTTP 服务器
      .get('/') // 发起 GET 请求
      .expect(200) // 断言 HTTP 状态码为 200
      .expect('Hello World!'); // 断言响应体为 'Hello World!'
  });
});

与单元测试的区别

  • 模块:单元测试创建一个临时的、只包含被测单元的模块;E2E 测试则加载了几乎整个应用的 AppModule
  • 目标:单元测试调用类的方法;E2E 测试通过 supertest 发送真实的 HTTP 请求。
  • 隔离性:单元测试高度隔离;E2E 测试测试的是多个组件协同工作的最终结果。

总结:如何选择测试类型?

单元测试 (Unit Test)端到端测试 (E2E Test)
测试对象单个类 (Service, Controller, Pipe...)整个应用或某个特性模块
速度非常快较慢
依赖大量使用模拟 (Mocks)几乎不使用模拟
覆盖范围专注于类的内部逻辑、边缘情况专注于请求-响应流程、中间件、守卫、数据库交互
信心指数较高(保证了每个零件合格)非常高(保证了整车能跑)

最佳实践:测试金字塔 一个健康的测试策略就像一个金字塔:

  • 底部(最多):大量的、快速的单元测试,覆盖你大部分的业务逻辑和边缘情况。
  • 中部(适量):一些集成测试(比 E2E 范围小,比如只测试 Controller 和 Service 的交互)。
  • 顶部(最少):少量的、关键的端到端测试,覆盖应用最核心的用户流程(如注册、登录、下单)。

测试是保证软件质量的基石。NestJS 提供的工具让编写各种类型的测试都变得得心应手。从今天起,就为你的每一个新功能都配上相应的“体检报告”吧!