测试 (Testing)
好的,我们终于来到了软件开发中一个至关重要的环节:测试 (Testing)。一个没有经过测试的应用,就像一艘没有经过试航的船,你永远不知道它在真正的风浪中会表现如何。NestJS 作为一个专业的框架,内置了对测试的一流支持,让编写测试变得简单而高效。
为你的代码“体检”:NestJS 测试完全入门指南
想象一下你是一位汽车工程师,你刚刚设计了一台全新的引擎。
在把这台引擎装上汽车进行路测之前,你会先在实验台上对它进行各种独立的测试:
- 单元测试 (Unit Testing):只测试引擎本身。你会给它接上燃料管和电线,启动它,然后检查它的转速、功率、油耗是否符合设计标准。你不会关心轮胎、变速箱或车身。
- 端到端测试 (End-to-End, E2E Testing):把引擎装进完整的汽车里,然后让一位试车员把车开到真实的赛道上跑几圈。你测试的是整个系统——从踩下油门踏板,到引擎轰鸣,再到车轮飞转——这一整套流程是否顺畅。
在 NestJS 中,我们也会对应用进行这两种核心类型的测试,以确保代码的质量和可靠性。
1. 单元测试 (Unit Testing) - “在实验台上测试引擎”
核心思想:隔离。单元测试的目标是孤立地测试一个独立的“单元”(通常是一个类,比如一个 Service
或 Controller
),而不受其依赖项的干扰。
如何实现隔离? 通过模拟 (Mocking)。如果我们的 CatsService
依赖于 DatabaseService
,在测试 CatsService
时,我们不希望它真的去连接数据库。我们会创建一个假的 DatabaseService
(一个 Mock),这个假的服务会按照我们的指令返回预设好的数据。
NestJS 与 Jest 测试框架深度集成,提供了强大的工具来简化这个过程。
实战:测试 CatsService
假设我们有这样一个 CatsService
。
src/cats/cats.service.ts
@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
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 应用上下文,只包含你明确指定的 providers
和 controllers
。这就像为你的引擎搭建一个临时的实验台。
现在,让我们添加更有意义的测试用例:
// ...接上面的代码
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');
});
});
如何运行测试? 在你的终端中运行:
npm run test
Jest 会自动找到所有 .spec.ts
文件并执行它们。
单元测试与模拟 (Mocking)
现在来个更复杂的,如果 CatsService
依赖别的服务怎么办?
// 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)
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 提供的工具让编写各种类型的测试都变得得心应手。从今天起,就为你的每一个新功能都配上相应的“体检报告”吧!