端到端(E2E)测试
一、E2E vs 单元测试
| 维度 | 单元测试 | E2E 测试 |
|---|---|---|
| 测试范围 | 单个 Service/Guard | 完整 HTTP 请求→响应链路 |
| 依赖 | Mock 所有外部依赖 | 真实数据库、真实 HTTP |
| 速度 | 极快(毫秒级) | 慢(秒级) |
| 主要发现 | 业务逻辑 Bug | 路由/DTO/数据库集成问题 |
| 运行频率 | 每次 commit | 每次 PR / 发布前 |
原则:E2E 测试关注业务流程的"黄金路径"和关键边界,不追求 100% 覆盖。
二、基础环境搭建
typescript
// test/jest-e2e.json
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"testTimeout": 30000
}typescript
// test/setup.ts(全局测试前置)
import { DataSource } from 'typeorm';
// 每次运行 E2E 前清空测试数据库
export async function clearDatabase(dataSource: DataSource) {
const entities = dataSource.entityMetadatas;
for (const entity of entities) {
const repo = dataSource.getRepository(entity.name);
await repo.query(`DELETE FROM "${entity.tableName}"`);
}
}三、完整 E2E 测试套件
typescript
// test/users.e2e-spec.ts
import * as request from 'supertest';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { getDataSourceToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
describe('用户接口 (E2E)', () => {
let app: INestApplication;
let dataSource: DataSource;
let authToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
// 使用测试数据库配置
.overrideProvider('DATABASE_URL')
.useValue(process.env.TEST_DATABASE_URL)
.compile();
app = moduleFixture.createNestApplication();
// 复现 main.ts 中的全局配置(否则 DTO 验证不生效)
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
await app.init();
dataSource = app.get(getDataSourceToken());
});
beforeEach(async () => {
// 每个测试前清空数据(隔离测试)
await clearDatabase(dataSource);
});
afterAll(async () => {
await app.close();
});
// ── 注册 ────────────────────────────────────────
describe('POST /auth/register', () => {
it('成功注册并返回 Token', async () => {
const response = await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'test@example.com',
name: '张三',
password: 'Password123!',
})
.expect(201);
expect(response.body.access_token).toBeDefined();
expect(response.body.refresh_token).toBeDefined();
expect(response.body.user?.password).toBeUndefined(); // 不应返回密码
});
it('邮箱格式错误返回 400', () => {
return request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'not-an-email', name: '张三', password: 'Password123!' })
.expect(400)
.expect(res => {
expect(res.body.message).toContain('email');
});
});
it('重复注册同一邮箱返回 409', async () => {
const dto = { email: 'dup@example.com', name: '张三', password: 'Password123!' };
await request(app.getHttpServer()).post('/auth/register').send(dto);
return request(app.getHttpServer())
.post('/auth/register')
.send(dto)
.expect(409);
});
});
// ── 登录 ────────────────────────────────────────
describe('POST /auth/login', () => {
beforeEach(async () => {
// 先注册一个用户
const res = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'login@example.com', name: '测试用户', password: 'Password123!' });
authToken = res.body.access_token;
});
it('正确凭证登录成功', () => {
return request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'login@example.com', password: 'Password123!' })
.expect(200)
.expect(res => {
expect(res.body.access_token).toBeDefined();
});
});
it('密码错误返回 401', () => {
return request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'login@example.com', password: 'WrongPassword!' })
.expect(401);
});
});
// ── 受保护的路由 ──────────────────────────────────
describe('GET /users/me', () => {
it('携带有效 Token 返回当前用户', async () => {
// 先注册获取 token
const registerRes = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'me@example.com', name: '当前用户', password: 'Password123!' });
const token = registerRes.body.access_token;
return request(app.getHttpServer())
.get('/users/me')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect(res => {
expect(res.body.email).toBe('me@example.com');
expect(res.body.password).toBeUndefined();
});
});
it('无 Token 返回 401', () => {
return request(app.getHttpServer())
.get('/users/me')
.expect(401);
});
it('Token 格式错误返回 401', () => {
return request(app.getHttpServer())
.get('/users/me')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
});
});四、测试数据库策略
方案一:独立测试数据库(推荐)
bash
# docker-compose.test.yml
services:
postgres-test:
image: postgres:15-alpine
environment:
POSTGRES_DB: nestjs_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5433:5432'] # 不同端口,避免与开发数据库冲突# .env.test
TEST_DATABASE_URL=postgres://test:test@localhost:5433/nestjs_test
NODE_ENV=testbash
# 运行 E2E 测试前启动测试数据库
docker-compose -f docker-compose.test.yml up -d
npm run test:e2e方案二:SQLite 内存数据库(快速但有差异)
typescript
// 在测试模块中覆盖 TypeORM 配置
.overrideModule(TypeOrmModule)
.useModule(TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [User, Post],
synchronize: true, // 测试中可以开 synchronize
}))⚠️ SQLite 与 PostgreSQL 行为差异:枚举类型、JSON 字段、ILIKE 等在 SQLite 中不可用
方案三:测试容器(最接近生产)
bash
npm install -D @testcontainers/postgresqltypescript
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
// 自动拉取并启动 PostgreSQL 容器
container = await new PostgreSqlContainer('postgres:15').start();
const module = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
url: container.getConnectionUri(),
entities: [User, Post],
synchronize: true,
}),
],
}).compile();
// ...
});
afterAll(async () => {
await container.stop();
});五、认证流程 E2E 测试
typescript
// 辅助函数:注册并获取 Token(在多个测试中复用)
async function registerAndLogin(
app: INestApplication,
overrides: Partial<RegisterDto> = {},
): Promise<{ token: string; userId: number }> {
const dto: RegisterDto = {
email: `test-${Date.now()}@example.com`,
name: '测试用户',
password: 'Password123!',
...overrides,
};
const res = await request(app.getHttpServer())
.post('/auth/register')
.send(dto)
.expect(201);
return { token: res.body.access_token, userId: res.body.user.id };
}
// 使用辅助函数
describe('DELETE /users/:id(管理员接口)', () => {
it('管理员可以删除其他用户', async () => {
const { userId: targetId } = await registerAndLogin(app);
// 用管理员账号登录(提前在数据库中 seed)
const adminToken = await getAdminToken(app);
return request(app.getHttpServer())
.delete(`/users/${targetId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(204);
});
it('普通用户无法删除其他用户', async () => {
const { userId: targetId } = await registerAndLogin(app);
const { token: userToken } = await registerAndLogin(app);
return request(app.getHttpServer())
.delete(`/users/${targetId}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
});
});六、在 CI 中运行 E2E 测试
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: nestjs_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5433:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:e2e
env:
TEST_DATABASE_URL: postgres://test:test@localhost:5433/nestjs_test
JWT_SECRET: test-secret-for-ci-only-at-least-32-chars
NODE_ENV: test可运行 Demo:
practice/06-testing— supertest e2e 测试(9个),npm run test:e2e
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
| e2e 测试间数据污染 | 共享同一个数据库且未清理 | beforeEach 中清空测试数据;或每次测试用唯一邮箱/数据 |
app.close() 后仍有异步操作报错 | 定时器/队列未关闭 | afterAll 中 await app.close();BullMQ worker 加 worker.close() |
| TestContainers 启动超时 | 网络慢或 Docker 镜像未拉取 | CI 中预拉取镜像;本地开发用 TESTCONTAINERS_RYUK_DISABLED=true |
| 全局 Pipe/Guard 在测试中不生效 | createTestingModule 不自动应用全局配置 | 在 app.useGlobalPipes(...) 之后再 app.init() |