NestJS 测试实战指南:面向初学者的完整实践

78 阅读6分钟

NestJS 测试实战指南:面向初学者的完整实践

面向第一次写 Nest 测试的你:本文会从“为什么测试”、“测试有哪些类型”讲起,再到环境配置、单测/集成测/e2e 的完整示例,结合真实代码让你学会在 Nest 项目中写可靠的测试。


1. 测试在项目中的价值

维度解决的问题
产品质量及时发现逻辑错误、回归缺陷,避免“上线即翻车”
迭代效率快速验证新功能/重构不会破坏旧功能
代码可靠性自动化测试让团队对交付更有信心,避免人肉测试带来的疏漏
文档作用测试用例本身就是活文档,告诉后来者“这个函数应该怎么用、输入输出是什么”

在我们这个项目里(Nest + TypeORM + PostgreSQL),测试主要覆盖:

  • 单元测试(Unit Test):验证 Service 层逻辑、DTO 校验、异常处理。
  • 集成测试(Integration Test):多个模块协同工作时的行为(例如库存模块调用多个 Repository)。
  • 端到端测试(E2E Test):从 HTTP 请求到数据库返回的全链路验证。

2. NestJS 测试体系概览

Nest 默认使用 Jest 作为测试框架,自带 @nestjs/testing 模块帮助我们构建测试模块。

2.1 Jest 的特点

  • 零配置即可在 TS 项目中运行(Nest CLI 已预置)。
  • 支持快照、Mock、并行执行。
  • 与 TS、Babel、ts-jest 无缝组合。

2.2 测试分类

类型粒度优点场景
单元测试独立类/函数快速、定位精确DTO 校验、Service 方法、简单业务逻辑
集成测试多个模块组合更贴近真实复杂业务流程(如库存调整)
E2E 测试整个应用最真实REST API、GraphQL 端点、外部接口

3. 环境准备

3.1 基础依赖

Nest 项目默认包含测试配置,如需新增:

npm install --save-dev jest @types/jest ts-jest npm install --save-dev supertest @types/supertest在 package.json 里已自带脚本:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:e2e": "jest --config ./test/jest-e2e.json",
    "test:cov": "jest --coverage"
  }
}### 3.2 `jest.config.js`(单元测试)

module.exports = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: 'src',
  testRegex: '.*\\.spec\\.ts$',
  transform: {
    '^.+\\.(t|j)s$': 'ts-jest',
  },
  collectCoverageFrom: ['**/*.(t|j)s'],
  coverageDirectory: '../coverage',
  testEnvironment: 'node',
};### 3.3 `test/jest-e2e.json`

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": "./",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
  "testEnvironment": "node"
}

4. 单元测试:Service 层实践

ItemsService 为例,如何 mock Repository 并验证业务逻辑。

4.1 Mock Repository & QueryBuilder

/* eslint-disable @typescript-eslint/unbound-method */
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ItemsService } from './items.service';
import { Item } from './entities/item.entity';

type MockRepository<T extends ObjectLiteral> = Partial<
  Record<'findOne' | 'create' | 'save' | 'remove' | 'createQueryBuilder' | 'find', jest.Mock>
> & {
  createQueryBuilder?: jest.Mocked<SelectQueryBuilder<T>>;
};

const createQueryBuilderMock = <T extends ObjectLiteral>(result: T[]) => {
  const qb: Partial<jest.Mocked<SelectQueryBuilder<T>>> = {
    leftJoinAndSelect: jest.fn().mockReturnThis(),
    orderBy: jest.fn().mockReturnThis(),
    andWhere: jest.fn().mockReturnThis(),
    getMany: jest.fn().mockResolvedValue(result),
    getOne: jest.fn().mockResolvedValue(result[0] ?? null),
    distinct: jest.fn().mockReturnThis(),
  };
  return qb as jest.Mocked<SelectQueryBuilder<T>>;
};

describe('ItemsService', () => {
  let service: ItemsService;
  let itemsRepository: MockRepository<Item>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        ItemsService,
        {
          provide: getRepositoryToken(Item),
          useValue: {
            findOne: jest.fn(),
            create: jest.fn(),
            save: jest.fn(),
            remove: jest.fn(),
            createQueryBuilder: jest.fn(),
          },
        },
        // 其他依赖的 Repository ...
      ],
    }).compile();

    service = moduleRef.get(ItemsService);
    itemsRepository = moduleRef.get(getRepositoryToken(Item));
  });

  it('should list items with filters applied', async () => {
    const qb = createQueryBuilderMock<Item>([{ id: 'item-1' } as Item]);
    (itemsRepository.createQueryBuilder as jest.Mock).mockReturnValue(qb);

    const result = await service.findAll({ keyword: '牛奶' });

    expect(itemsRepository.createQueryBuilder).toHaveBeenCalledWith('item');
    expect(qb.andWhere).toHaveBeenCalled();
    expect(result).toHaveLength(1);
  });

  it('should create item successfully', async () => {
    (itemsRepository.createQueryBuilder as jest.Mock).mockReturnValue(createQueryBuilderMock<Item>([]));
    (itemsRepository.create as jest.Mock).mockReturnValue({ id: 'item-1' });
    (itemsRepository.save as jest.Mock).mockImplementation((value) => value);

    const result = await service.create({
      categoryId: 'cat-1',
      name: '牛奶',
      unit: '瓶',
    });

    expect(result.id).toBe('item-1');
  });
});**关键点:**

- 使用 `getRepositoryToken(Entity)` 获取对应 Repository。
- 通过 jest 提供的 `jest.fn()``mockReturnValue` 等实现 Mock。
- 复杂查询(QueryBuilder)需要额外构造 `createQueryBuilderMock`

5. E2E 测试:Supertest + SQLite 内存库

验证整个 HTTP 请求 → Service → Repository → DB → 返回响应的流程。

5.1 示例:test/items.e2e-spec.ts

import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken, TypeOrmModule } from '@nestjs/typeorm';
import request from 'supertest';
import { ItemsModule } from '../src/modules/items/items.module';
import { Item } from '../src/modules/items/entities/item.entity';
import { Category } from '../src/modules/categories/entities/category.entity';
import { Location } from '../src/modules/locations/entities/location.entity';
import { Tag } from '../src/modules/tags/entities/tag.entity';
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
import { TransformInterceptor } from '../src/common/interceptors/transform.interceptor';

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

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: ':memory:',
          entities: [Category, Location, Item, Tag],
          synchronize: true,
        }),
        ItemsModule,
      ],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        transform: true,
        transformOptions: { enableImplicitConversion: true },
      }),
    );
    app.useGlobalFilters(new HttpExceptionFilter());
    app.useGlobalInterceptors(new TransformInterceptor());
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('should create, query, update and delete item successfully', async () => {
    const agent = request.agent(app.getHttpServer());

    const createResponse = await agent
      .post('/items')
      .send({
        categoryId: 'uuid-cat',
        name: '有机牛奶',
        unit: '瓶',
      })
      .expect(201);

    const detailResponse = await agent
      .get(`/items/${createResponse.body.data.id}`)
      .expect(200);

    expect(detailResponse.body.data.name).toBe('有机牛奶');
  });
});**关键点:**

- 使用 `TypeOrmModule.forRoot` + `sqlite :memory:`,每个测试运行时都构造独立数据库。
- 全局挂载 `ValidationPipe``HttpExceptionFilter``TransformInterceptor`,确保与实际运行环境一致。
- 使用 `request.agent` 模拟 HTTP 请求。

6. 集成测试思路(示意)

介于单测与 e2e 之间:通常会启动部分模块(含真实 Repository),但不通过 HTTP。

示例(缩写):

describe('PurchaseRecordsService', () => {
  let service: PurchaseRecordsService;
  let dataSource: DataSource;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({ type: 'sqlite', database: ':memory:', entities: [...] }),
        TypeOrmModule.forFeature([...实体]),
      ],
      providers: [PurchaseRecordsService, StockService],
    }).compile();

    dataSource = moduleRef.get(DataSource);
    service = moduleRef.get(PurchaseRecordsService);
  });

  it('should confirm draft and create stock when missing', async () => {
    // 使用真实 Repository + Service 验证事务逻辑
  });
});

7. 测试约定与最佳实践

  1. 命名约定

    • 单元测试:*.spec.ts 存放在 src/ 同目录。
    • E2E 测试:*.e2e-spec.ts 存放在 test/ 目录。
  2. 快速执行

    • npm run test(单元测试)
    • npm run test:e2e(端到端)
  3. Mock vs 实际依赖

    • Service 层优先 Mock Repository,确保测试聚焦逻辑。
    • 涉及多模块协作、事务时考虑集成测试。
  4. 保持一致的测试环境

    • e2e 需使用和生产一致的中间件(全局 Filter、Interceptor)。
    • 数据库连接建议使用内存库或独立实例,避免影响真实数据。
  5. 测试数据准备

    • 单测用 mock 数据;
    • e2e 集成通过 Repository/HTTP 插入数据;
    • 注意测试间互不影响(beforeEach reset,afterEach 清理)。
  6. 断言尽量明确

    • 检查返回值、异常、调用次数(expect(mock).toHaveBeenCalledWith(...))。

8. 与内建中间件/拦截器结合

测试时需要考虑全局中间件的影响:

app.useGlobalPipes(new ValidationPipe({ ... }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());这样 e2e 测试得到的响应结构与生产一致(`{ success, data, message, timestamp }`)。

9. 常见坑与排查

问题排查建议
TypeError: Class constructor ... cannot be invoked without 'new'未使用 Test.createTestingModule,或模块装饰器缺失 @Module
No repository for "Entity" was found.没有在测试模块中引入 TypeOrmModule.forFeature([Entity])
SQLite e2e 运行异常包版本问题或未正确关闭 app.close()
测试之间互相污染使用 beforeEach 重置 mocks 或重新构建模块
Mock QueryBuilder 逻辑复杂单独封装 createQueryBuilderMock,并写好 returnThis
端口占用(EADDRINUSE)旧实例未关闭,确保测试结束 await app.close()

10. 示例命令集

单元测试

npm run test

单元测试并 watch

npm run test:watch

生成覆盖率报告

npm run test:cov

端到端测试

npm run test:e2e---

11. 总结

  • 先单测、后集成、再 e2e:逐层构建,定位问题更快。
  • Mock 合理使用:Service 单测 mock Repository,避免连接真实 DB。
  • 内存数据库(SQLite)适合 e2e:快速、隔离、不依赖外部服务。
  • 借助 Nest 提供的 TestingModule:模拟真实模块结构,少走弯路。
  • 测试即文档:好的测试用例能告诉团队“该功能应该怎样运行”。

掌握测试后,你的 Nest 项目就拥有了“自我保护力”——重构、扩展、上线都更安心。希望这篇文章对你有帮助,欢迎把你的实践心得也分享出来,一起交流!🎯