首页 / NestJS 入门教程 / E2E测试

NestJS 入门教程

E2E测试

本教程共 47 篇 · 第 45 篇 · 更新于 2026-08-09 · 约 12 分钟阅读

NestJSE2E测试Supertest集成测试HTTP测试

本节目标:掌握NestJS端到端测试,学会用Supertest模拟HTTP请求,测试完整的API流程,确保系统各组件协同工作。

单元测试测试单个类,E2E测试测试整个系统。从HTTP请求进去,走完控制器、服务、数据库,验证整个流程是否正确。就像真实用户在使用你的API。

安装依赖

E2E测试需要Supertest来模拟HTTP请求:

npm install --save-dev supertest @types/supertest

Nest CLI创建的项目已经自带E2E测试配置。看看test/jest-e2e.json

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

testRegex匹配.e2e-spec.ts结尾的文件。

基本E2E测试

用Nest CLI创建的项目已经有个示例E2E测试,在test/app.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

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

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

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

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});

跟单元测试的区别:

  • createNestApplication()创建完整的应用实例
  • app.init()初始化应用
  • request(app.getHttpServer())发送HTTP请求
  • 测试结束后用app.close()关闭应用
Note

beforeAll在所有测试前执行一次,afterAll在所有测试后执行。beforeEachafterEach则在每个测试前后执行。

测试CRUD接口

假设有个用户管理API,测试完整的增删改查流程。

注册和登录

describe('Authentication', () => {
  let accessToken: string;

  it('should register a new user', () => {
    return request(app.getHttpServer())
      .post('/auth/register')
      .send({
        name: 'Test User',
        email: 'test@example.com',
        password: 'password123',
      })
      .expect(201)
      .expect((res) => {
        expect(res.body).toHaveProperty('access_token');
        accessToken = res.body.access_token;
      });
  });

  it('should login with valid credentials', () => {
    return request(app.getHttpServer())
      .post('/auth/login')
      .send({
        email: 'test@example.com',
        password: 'password123',
      })
      .expect(201)
      .expect((res) => {
        expect(res.body).toHaveProperty('access_token');
        accessToken = res.body.access_token;
      });
  });

  it('should fail login with invalid credentials', () => {
    return request(app.getHttpServer())
      .post('/auth/login')
      .send({
        email: 'test@example.com',
        password: 'wrongpassword',
      })
      .expect(401);
  });
});

send()发送请求体,expect()断言响应状态码或内容。

用户操作

describe('Users', () => {
  it('should return all users', () => {
    return request(app.getHttpServer())
      .get('/users')
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(200)
      .expect((res) => {
        expect(Array.isArray(res.body)).toBe(true);
      });
  });

  it('should return a user by id', () => {
    return request(app.getHttpServer())
      .get('/users/1')
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(200)
      .expect((res) => {
        expect(res.body).toHaveProperty('id', 1);
      });
  });

  it('should return 404 for non-existent user', () => {
    return request(app.getHttpServer())
      .get('/users/99999')
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(404);
  });

  it('should create a new user', () => {
    return request(app.getHttpServer())
      .post('/users')
      .set('Authorization', `Bearer ${accessToken}`)
      .send({
        name: 'New User',
        email: 'newuser@example.com',
        password: 'password123',
      })
      .expect(201)
      .expect((res) => {
        expect(res.body).toHaveProperty('id');
        expect(res.body.name).toBe('New User');
      });
  });

  it('should update a user', () => {
    return request(app.getHttpServer())
      .patch('/users/1')
      .set('Authorization', `Bearer ${accessToken}`)
      .send({ name: 'Updated Name' })
      .expect(200)
      .expect((res) => {
        expect(res.body.name).toBe('Updated Name');
      });
  });

  it('should delete a user', () => {
    return request(app.getHttpServer())
      .delete('/users/1')
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(200);
  });
});

set()设置请求头,用来传Authorization Token。

测试验证

验证管道会拒绝不合法的数据:

describe('Validation', () => {
  it('should fail with invalid email', () => {
    return request(app.getHttpServer())
      .post('/auth/register')
      .send({
        name: 'Test User',
        email: 'invalid-email',
        password: 'password123',
      })
      .expect(400);
  });

  it('should fail with short password', () => {
    return request(app.getHttpServer())
      .post('/auth/register')
      .send({
        name: 'Test User',
        email: 'test@example.com',
        password: '123',
      })
      .expect(400);
  });

  it('should fail with missing required fields', () => {
    return request(app.getHttpServer())
      .post('/auth/register')
      .send({ name: 'Test User' })
      .expect(400);
  });
});

验证失败返回400状态码。

覆盖提供者

E2E测试通常用真实的数据库和服务。但有时候想替换某些服务,比如不发真实的邮件:

const moduleFixture: TestingModule = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(EmailService)
  .useValue({
    sendEmail: jest.fn().mockResolvedValue(true),
  })
  .compile();

overrideProvider()跟单元测试里一样,可以替换任何提供者。

使用测试数据库

E2E测试最好用独立的测试数据库,别污染开发数据。

内存数据库

用SQLite内存数据库,测试完数据自动消失:

import { TypeOrmModule } from '@nestjs/typeorm';

beforeAll(async () => {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [
      TypeOrmModule.forRoot({
        type: 'sqlite',
        database: ':memory:',
        entities: [__dirname + '/../src/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      AppModule,
    ],
  }).compile();

  app = moduleFixture.createNestApplication();
  await app.init();
});

synchronize: true自动根据实体创建表结构。

清理数据

每个测试后清理数据,保证测试独立:

import { DataSource } from 'typeorm';

describe('UsersController (e2e)', () => {
  let app: INestApplication;
  let dataSource: DataSource;

  beforeAll(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    dataSource = moduleFixture.get(DataSource);
    await app.init();
  });

  afterEach(async () => {
    // 清空所有表
    const entities = dataSource.entityMetadatas;
    for (const entity of entities) {
      const repository = dataSource.getRepository(entity.name);
      await repository.query(`DELETE FROM ${entity.tableName}`);
    }
  });

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

测试工具函数

测试多了,重复代码就多。提取工具函数简化测试。

创建测试用户

async function createTestUser(app: INestApplication) {
  const response = await request(app.getHttpServer())
    .post('/auth/register')
    .send({
      name: 'Test User',
      email: `test${Date.now()}@example.com`,
      password: 'password123',
    });

  return {
    user: response.body.user,
    accessToken: response.body.access_token,
  };
}

使用时:

it('should do something', async () => {
  const { accessToken } = await createTestUser(app);
  
  return request(app.getHttpServer())
    .get('/users')
    .set('Authorization', `Bearer ${accessToken}`)
    .expect(200);
});

认证请求辅助函数

function authRequest(
  app: INestApplication,
  method: 'get' | 'post' | 'put' | 'patch' | 'delete',
  url: string,
  token: string,
) {
  return request(app.getHttpServer())[method](url)
    .set('Authorization', `Bearer ${token}`);
}

使用时:

it('should return users', () => {
  return authRequest(app, 'get', '/users', accessToken)
    .expect(200);
});

测试文件上传

describe('File Upload', () => {
  it('should upload a file', () => {
    return request(app.getHttpServer())
      .post('/upload')
      .set('Authorization', `Bearer ${accessToken}`)
      .attach('file', 'test/fixtures/sample.png')
      .expect(201)
      .expect((res) => {
        expect(res.body).toHaveProperty('filename');
        expect(res.body).toHaveProperty('url');
      });
  });

  it('should reject invalid file type', () => {
    return request(app.getHttpServer())
      .post('/upload')
      .set('Authorization', `Bearer ${accessToken}`)
      .attach('file', 'test/fixtures/sample.exe')
      .expect(400);
  });
});

attach()上传文件,第一个参数是字段名,第二个是文件路径。

运行E2E测试

npm run test:e2e

这个命令会读取test/jest-e2e.json配置,运行所有.e2e-spec.ts文件。

Tip

E2E测试比单元测试慢,因为要启动完整的应用、连接数据库。开发时可以只跑单元测试,提交代码前再跑E2E测试。

CI/CD集成

在CI环境跑E2E测试,需要配置测试数据库。以GitHub Actions为例:

name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run test:e2e
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test

启动PostgreSQL容器,设置环境变量,跑E2E测试。

最佳实践

测试要独立

每个测试不依赖其他测试的结果。测试A的失败不应该影响测试B。

清理数据

每次测试后清理数据,保证测试环境干净。不然测试之间会互相干扰。

用环境变量

beforeAll(async () => {
  process.env.NODE_ENV = 'test';
  // 根据环境加载不同配置
});

测试环境和开发环境用不同的数据库、不同的配置。

测试边界情况

不只测正常流程,还要测异常情况:

  • 参数缺失
  • 参数格式错误
  • 权限不足
  • 资源不存在

测试顺序

测试有依赖关系时,用describe分组,按顺序执行:

describe('User Flow', () => {
  it('1. should register', () => {});
  it('2. should login', () => {});
  it('3. should get profile', () => {});
  it('4. should update profile', () => {});
  it('5. should logout', () => {});
});
Warning

E2E测试不能覆盖所有场景。复杂的业务逻辑还是用单元测试。E2E测试主要验证API接口是否正确,各组件是否正确集成。

小结

这一章学了NestJS E2E测试:

基本结构:用createNestApplication()创建应用,supertest发送HTTP请求。

测试CRUD:测试注册、登录、增删改查等完整流程。

覆盖提供者:用overrideProvider()替换特定服务,比如不发真实邮件。

测试数据库:用SQLite内存数据库或独立的测试数据库,测试后清理数据。

工具函数:提取重复代码,简化测试。

文件上传:用attach()测试文件上传接口。

CI/CD集成:在CI环境配置测试数据库,自动跑E2E测试。

E2E测试是最后一道防线。单元测试保证每个零件没问题,E2E测试保证整台机器能正常运转。下一章我们聊部署上线。