测试
这一课你将能做到
- 用 Jest 配合
Test.createTestingModule 写出 QuestionsService 的完整单元测试
- 用 mock Provider 替换依赖(如给 SubmissionsService 注入假的 QuestionsService)
- 用 supertest 编写 e2e 测试,验证 200 / 400 / 401 / 403 / 404 等真实 HTTP 契约
为什么要学
只靠 Postman 手工点接口,短期快、长期是灾难:改一次代码要点一遍,漏一个接口 Bug 就流出。
Nest 默认集成 Jest,测试分两层:单元测试不启动端口,只装配微型 DI 容器,快速验证业务分支与异常;e2e 启动接近生产的应用,用 supertest 发真实 HTTP 请求,Guard、Pipe、Filter 全部参与,验证对外契约。这对应 Java 侧的 JUnit 加 Mockito(单元)和 SpringBootTest 加 MockMvc(集成)。
先建立图像
单测:createTestingModule({ providers }).compile() → get(Service) → 断言
e2e:createTestingModule({ imports: [AppModule] }) → createNestApplication()
→ 复现 main.ts 装配 → init() → request(httpServer).expect(200)
| 层级 | 验证什么 | 不验证什么 |
|---|
| 单元 | 业务分支、边界值、异常类型 | 真实网络与数据库(一律 mock) |
| e2e | 路由、Pipe、Guard、Filter、状态码 | 私有方法的实现细节 |
跟着做
步骤 1:跑通脚手架自带测试
npm test
Jest 以 watch 模式启动(CI 用 npm run test:ci)。确认示例通过后再替换为业务用例。
步骤 2:补全 QuestionsService 单元测试
替换 src/questions/questions.service.spec.ts:
import { Test } from "@nestjs/testing";
import {
ConflictException,
NotFoundException,
} from "@nestjs/common";
import { QuestionsService } from "./questions.service";
describe("QuestionsService", () => {
let service: QuestionsService;
beforeEach(async () => {
service = (
await Test.createTestingModule({ providers: [QuestionsService] }).compile()
).get(QuestionsService);
});
it("findAll 返回数组,含种子题", () => {
expect(service.findAll()[0].stem).toContain("TypeScript");
});
it("findOne 未命中抛 NotFoundException", () => {
expect(() => service.findOne(999)).toThrow(NotFoundException);
});
it("create 后再 findOne 能查到", () => {
const created = service.create({
stem: "Nest 中做权限控制的是?",
options: ["Guard", "Pipe", "Filter"],
answerIndex: 0,
difficulty: "medium",
});
expect(service.findOne(created.id).difficulty).toBe("medium");
});
it("create 重复题干抛 ConflictException", () => {
expect(() =>
service.create({
stem: "TypeScript 中,用来描述对象形状的类型是?",
options: ["A", "B"],
answerIndex: 0,
difficulty: "easy",
}),
).toThrow(ConflictException);
});
it("remove 删除存在的题目不抛错,再查抛 404", () => {
service.remove(1);
expect(() => service.findOne(1)).toThrow(NotFoundException);
});
});
执行 npm test -- questions.service,6 个用例应全部通过。
步骤 3:mock 依赖测试 SubmissionsService
新建 src/submissions/submissions.service.spec.ts:
import { Test } from "@nestjs/testing";
import { QuestionsService } from "../questions/questions.service";
import { SubmissionsService } from "./submissions.service";
describe("SubmissionsService", () => {
let service: SubmissionsService;
const mockQuestions = {
findOne: jest.fn((id: number) => ({
id,
stem: "测试题",
options: ["A", "B", "C"],
answerIndex: 1,
difficulty: "easy",
tags: [],
})),
};
beforeEach(async () => {
service = (
await Test.createTestingModule({
providers: [
SubmissionsService,
{ provide: QuestionsService, useValue: mockQuestions },
],
}).compile()
).get(SubmissionsService);
});
it("选对答案 correct=true", () => {
expect(service.submit(1, 1001, 1).correct).toBe(true);
});
it("选错答案 correct=false", () => {
expect(service.submit(1, 1001, 0).correct).toBe(false);
});
it("stats 统计两次提交、正确率 50", () => {
service.submit(1, 1001, 1);
service.submit(1, 1001, 2);
expect(service.stats(1001)).toEqual({ total: 2, correct: 1, accuracy: 50 });
});
});
运行 npm test -- submissions.service。全程没有真实 QuestionsService,行为完全由 mock 控制——这就是 DI 的测试红利。
步骤 4:搭建 e2e 测试骨架
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { AppModule } from "../src/app.module";
describe("Quiz API (e2e)", () => {
let app: INestApplication;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
// 关键:必须与 main.ts 的全局装配完全一致
app.setGlobalPrefix("api");
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
});
afterEach(async () => {
await app.close();
});
it("GET /api/questions → 200 且返回数组", () => {
return request(app.getHttpServer())
.get("/api/questions")
.expect(200)
.expect((res) => expect(Array.isArray(res.body)).toBe(true));
});
});
更好的工程做法:把全局装配抽成一个 setupApp(app) 函数,main.ts 和 e2e 共用,从根源杜绝「测试绿、线上挂」。
步骤 5:e2e 覆盖失败契约与鉴权
async function login(username: string, password: string) {
const res = await request(app.getHttpServer())
.post("/api/auth/login")
.send({ username, password });
return res.body.access_token as string;
}
it("GET 不存在的题目 → 404", () => {
return request(app.getHttpServer()).get("/api/questions/999").expect(404);
});
it("POST 非法题目体 → 400", async () => {
const token = await login("admin", "admin123");
return request(app.getHttpServer())
.post("/api/questions").set("Authorization", `Bearer ${token}`)
.send({ stem: "x", options: [] }).expect(400);
});
it("学生创建题目 → 403", async () => {
const token = await login("student1", "123456");
return request(app.getHttpServer())
.post("/api/questions").set("Authorization", `Bearer ${token}`)
.send({ stem: "合法题干", options: ["A", "B"], answerIndex: 0, difficulty: "easy" })
.expect(403);
});
it("未登录提交作答 → 401", () => {
return request(app.getHttpServer())
.post("/api/submissions")
.send({ questionId: 1, selectedIndex: 0 })
.expect(401);
});
执行 npm run test:e2e,Guard、Pipe、Filter 全部被真实打到;导入报错则改为 import * as request from "supertest"。
步骤 6:把测试变成提交习惯
npm test && npm run test:e2e
CI 同样配置这两条命令,失败即阻止合并。重构和重命名时,测试就是安全网。
讲清楚要点
- 单元测试靠 DI 换依赖:
useValue 提供假实现,快且确定。
- e2e 复现生产装配:前缀、Pipe、Filter 一个不能少。
- 断言契约而非实现:断言状态码加关键字段;整个大 JSON 一改版就碎。
- 用例隔离:beforeEach 重建容器,内存数据必要时显式清理。
- 先覆盖主路径:列表、404、校验、401/403,比覆盖率数字有价值。
- Jest 经验可迁移:describe/it/expect 与 Mockito 的 mock-when-verify 一一对应。
你可能会懵的地方
-
单元和 e2e 测的东西重复了?
初学者常觉得重复——不重复。单测定位精确、秒级反馈;e2e 防止「忘挂 Pipe、忘加前缀」这类集成错误,二者互补。
-
jest.fn 和 useValue 怎么配合?
useValue 放假对象,方法用 jest.fn 创建,就能断言 expect(mock.findOne).toHaveBeenCalledWith(1)。
-
测试里读不到 .env 怎么办?
显式 ConfigModule.forRoot({ envFilePath: ".env.test" }),或直接 mock ConfigService——后者更推荐。
-
并行跑测试会互相污染吗?
Jest 默认单进程顺序跑同文件用例;内存单例数据务必 beforeEach 重置。
-
私有方法要不要单独测?
不必。通过公有行为间接覆盖;绑死私有方法会让重构寸步难行。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 只手工点 Postman | 回归全靠记忆力 | 自动化单测 + e2e |
| e2e 忘记挂 ValidationPipe | 测试假绿、线上 400 | 抽公共装配函数复用 |
| 单测连真实数据库 | 慢、脏、危险 | mock Provider |
| 断言整个响应大对象 | 轻微改动测试全碎 | 断言状态码与关键字段 |
| 用例共享状态不清理 | 测试顺序依赖、偶发红 | beforeEach 重建容器 |
卡点急救
- e2e 全部 404:检查是否调用
setGlobalPrefix("api"),测试路径要带前缀。
- Token 获取失败:确认登录接口被
@Public() 放行;返回体 access_token 字段名一致。
- Jest 报端口占用:e2e 使用 supertest 时不应调用
app.listen;app.getHttpServer() 走的是内存监听,不占真实端口。
面试常问
-
为什么用 TestingModule 而不是直接 new?
它走与生产相同的 DI 解析,构造方式一致;有依赖时可注入 mock,减少「测试通过但装配有误」。
-
单元测试和 e2e 如何分工?
单测针对业务分支、依赖全 mock,快而准;e2e 启动完整应用验证 HTTP 契约。前者占多数,后者保关键路径。
-
supertest 的原理是什么?
直接接收 Node 的 httpServer,不走真实网络端口;配合 app.init()(不 listen)在内存中完成验证。
自测三问
Test.createTestingModule 在单元测试中替代了什么?
- 为什么 e2e 必须复现 main.ts 的全局前缀与 Pipe?
- mock QuestionsService 解决了测试中的哪些脆弱性?
小结一句话
TestingModule 隔离测业务,supertest 验证真实 HTTP 契约;两层装配都要与生产保持一致。
延伸