Skip to content

端到端(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=test
bash
# 运行 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/postgresql
typescript
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() 后仍有异步操作报错定时器/队列未关闭afterAllawait app.close();BullMQ worker 加 worker.close()
TestContainers 启动超时网络慢或 Docker 镜像未拉取CI 中预拉取镜像;本地开发用 TESTCONTAINERS_RYUK_DISABLED=true
全局 Pipe/Guard 在测试中不生效createTestingModule 不自动应用全局配置app.useGlobalPipes(...) 之后再 app.init()

NestJS 深度学习体系