【Nest】集成测试
什么是集成测试(Integration Test)
- 定义:集成测试在单元测试之上,验证模块之间真实交互是否正确(例如 service 与 repository、controller 与 service 的真实组合),不把内部重要依赖 mock 掉(或只 mock 外部第三方服务),但通常仍避免对生产外部系统(真实第三方 API、生产 DB)进行依赖。
- 目的:检验不同层/模块的组合行为(比如 ORM 查询能否按预期返回、controller 路由到 service 并把 DB 数据正确返回等),比单元测试更能发现集成面的错误,但比 e2e 更局部、更快。
常见场景
- Service + Repository 的集成测试:真实使用测试数据库(如 sqlite in-memory / test container DB),验证 ORM 查询、实体映射、事务行为。
- Controller HTTP 层集成测试(通常也称为部分 e2e):启动
INestApplication
(或FastifyAdapter
等),通过supertest
发 HTTP 请求,验证路由、管道、守卫和 service 的集成,但仍可用测试 DB、或 mock 外部服务。
集成测试的两种常见实现方式(对比)
- 使用真实测试 DB(推荐):例如 sqlite 内存或单独的测试 Postgres(Docker / Testcontainers)。优点:接近真实;缺点:需要注意隔离、启动速度。
- 用部分 mock(例如 mock 外部 API、但真实 DB):保持 DB 真实、网络/邮件/第三方被 mock,常见于业务有外部依赖时。
示例项目说明
假设有 User
实体、UsersService
、UsersController
,使用 TypeORM。我们做两个集成测试:
users.service.int-spec.ts
—— Service + Repository(TypeORM sqlite 内存)users.e2e-spec.ts
—— 启动 Nest 应用并用supertest
调用 HTTP 接口(使用同样的测试 DB)
代码示例
先给出最小实现用于测试:
// src/users/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';@Entity('users')
export class User {@PrimaryGeneratedColumn()id: number;@Column()name: string;
}
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { InjectRepository } from '@nestjs/typeorm';@Injectable()
export class UsersService {constructor(@InjectRepository(User)private readonly repo: Repository<User>,) {}create(name: string) {const u = this.repo.create({ name });return this.repo.save(u);}findOne(id: number) {return this.repo.findOneBy({ id });}findAll() {return this.repo.find();}
}
// src/users/users.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';@Controller('users')
export class UsersController {constructor(private readonly svc: UsersService) {}@Post()create(@Body('name') name: string) {return this.svc.create(name);}@Get()list() {return this.svc.findAll();}@Get(':id')get(@Param('id') id: string) {return this.svc.findOne(Number(id));}
}
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';@Module({imports: [TypeOrmModule.forFeature([User])],providers: [UsersService],controllers: [UsersController],
})
export class UsersModule {}
集成测试 A:Service + Repository(TypeORM + sqlite :memory:)
重点:在测试 TestingModule
中 导入 TypeOrmModule.forRoot
指向 sqlite 内存数据库,并导入 UsersModule
。设 synchronize: true
和 dropSchema: true
(测试专用)以保证干净的 schema。
// test/users.service.int-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsersService } from '../src/users/users.service';
import { User } from '../src/users/user.entity';describe('UsersService (integration)', () => {let moduleRef: TestingModule;let service: UsersService;let repo: Repository<User>;beforeAll(async () => {moduleRef = await Test.createTestingModule({imports: [// 连接到 sqlite in-memory 数据库,仅供测试使用TypeOrmModule.forRoot({type: 'sqlite',database: ':memory:',dropSchema: true, // 测试时重建 schemaentities: [User],synchronize: true, // 测试时自动同步实体(仅测试)logging: false,}),TypeOrmModule.forFeature([User]),],providers: [UsersService],}).compile();service = moduleRef.get<UsersService>(UsersService);repo = moduleRef.get<Repository<User>>(getRepositoryToken(User));});afterAll(async () => {// 关闭连接await moduleRef.close();});beforeEach(async () => {// 测试隔离:清空表(或使用事务回滚策略,见后文)await repo.clear();});it('should create and find user', async () => {const created = await service.create('alice');expect(created).toHaveProperty('id');expect(created.name).toBe('alice');const found = await service.findOne(created.id);expect(found.name).toBe('alice');});it('findAll returns multiple users', async () => {await service.create('u1');await service.create('u2');const all = await service.findAll();expect(all.length).toBe(2);const names = all.map(u => u.name).sort();expect(names).toEqual(['u1', 'u2']);});
});
要点解释:
- 使用 sqlite
:memory:
不会写磁盘,速度快。 dropSchema: true
+synchronize: true
可在测试每次启动时保证 schema 干净(注意:不要在生产使用)。repo.clear()
在每个beforeEach
中清数据保证测试隔离(也可以用事务回滚替代)。
集成测试 B:Controller HTTP 层集成测试(用 supertest)
这类测试会启动 INestApplication
,并使用 supertest
发起 HTTP 请求。仍然用 sqlite 内存 DB。也可以在测试时 overrideProvider 来 mock发送邮件、第三方 API 等。
// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from '../src/users/users.module';
import { User } from '../src/users/user.entity';describe('UsersController (e2e/integration)', () => {let app: INestApplication;beforeAll(async () => {const moduleFixture: TestingModule = await Test.createTestingModule({imports: [TypeOrmModule.forRoot({type: 'sqlite',database: ':memory:',dropSchema: true,entities: [User],synchronize: true,}),UsersModule,],}).compile();app = moduleFixture.createNestApplication();// 如果你在 controller 使用 ValidationPipe,记得在测试中也加上app.useGlobalPipes(new ValidationPipe({ whitelist: true }));await app.init();});afterAll(async () => {await app.close();});it('/users (POST) -> create and GET /users', async () => {const server = app.getHttpServer();// 创建用户await request(server).post('/users').send({ name: 'bob' }).expect(201).then(res => {expect(res.body).toHaveProperty('id');expect(res.body.name).toBe('bob');});// 列表const list = await request(server).get('/users').expect(200);expect(Array.isArray(list.body)).toBe(true);expect(list.body.length).toBe(1);expect(list.body[0].name).toBe('bob');});it('/users/:id (GET) -> 404 for missing', async () => {const server = app.getHttpServer();await request(server).get('/users/9999').expect(200); // 注意:Our service returns null; 若要返回 404 可在 controller 里处理});
});
注意:上面示例中 GET /users/9999
返回的状态取决于 controller/service 是否将 null
转化为 404;真实项目中你可能在 service 抛出 NotFoundException
或在 controller 中判断并抛 404。调整断言以匹配你的实现。