CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
NestJS 实战入门/课时/测试
RawBlame
课时目录12 节

NestJS 实战入门

  1. 01NestJS 是什么
  2. 02项目创建与结构
  3. 03Module 与依赖注入
  4. 04Controller 与路由
  5. 05Service 层
  6. 06Pipe 与校验
  7. 07Guard 与认证
  8. 08ConfigModule
  9. 09数据库接入入门
  10. 10异常与过滤器
  11. 11测试
  12. 12综合练习

测试

Jest、e2e supertest、mock Provider。

30 分钟

测试

这一课你将能做到

  1. 用 Jest 配合 Test.createTestingModule 写出 QuestionsService 的完整单元测试
  2. 用 mock Provider 替换依赖(如给 SubmissionsService 注入假的 QuestionsService)
  3. 用 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 测试骨架

替换 test/app.e2e-spec.ts:

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 覆盖失败契约与鉴权

在同一 describe 中追加:

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 同样配置这两条命令,失败即阻止合并。重构和重命名时,测试就是安全网。

讲清楚要点

  1. 单元测试靠 DI 换依赖:useValue 提供假实现,快且确定。
  2. e2e 复现生产装配:前缀、Pipe、Filter 一个不能少。
  3. 断言契约而非实现:断言状态码加关键字段;整个大 JSON 一改版就碎。
  4. 用例隔离:beforeEach 重建容器,内存数据必要时显式清理。
  5. 先覆盖主路径:列表、404、校验、401/403,比覆盖率数字有价值。
  6. 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 重建容器

卡点急救

  1. e2e 全部 404:检查是否调用 setGlobalPrefix("api"),测试路径要带前缀。
  2. Token 获取失败:确认登录接口被 @Public() 放行;返回体 access_token 字段名一致。
  3. Jest 报端口占用:e2e 使用 supertest 时不应调用 app.listen;app.getHttpServer() 走的是内存监听,不占真实端口。

面试常问

  • 为什么用 TestingModule 而不是直接 new? 它走与生产相同的 DI 解析,构造方式一致;有依赖时可注入 mock,减少「测试通过但装配有误」。

  • 单元测试和 e2e 如何分工? 单测针对业务分支、依赖全 mock,快而准;e2e 启动完整应用验证 HTTP 契约。前者占多数,后者保关键路径。

  • supertest 的原理是什么? 直接接收 Node 的 httpServer,不走真实网络端口;配合 app.init()(不 listen)在内存中完成验证。

自测三问

  1. Test.createTestingModule 在单元测试中替代了什么?
  2. 为什么 e2e 必须复现 main.ts 的全局前缀与 Pipe?
  3. mock QuestionsService 解决了测试中的哪些脆弱性?

小结一句话

TestingModule 隔离测业务,supertest 验证真实 HTTP 契约;两层装配都要与生产保持一致。

延伸

  • Fundamentals: Testing(官方测试文档)
  • Jest 官方文档
  • 下一课预告:综合练习,把全部零件组装成完整的刷题平台 API 并通过结业检查。
加载本节练习…
← 上一课异常与过滤器下一课 →综合练习