PAYATHON 2026

NestJS 后端的 E2E 测试应该如何编写,覆盖范围如何确定?

技术老齐

注册成功、邮箱无效、登录成功和重复注册这几个场景值得保留,但无需为后端所有可能的返回结果都编写 E2E 测试。可以用 E2E 验证关键业务流程和对外接口契约,把细致的业务分支交给单元测试,再通过集成测试验证数据库等组件的真实行为。

现有代码需要优先解决测试之间的数据依赖,并调整测试环境配置和数据清理方式。

如何确定 E2E 测试的覆盖范围

先列出业务必须保证的行为,再决定由哪一层测试负责。认证模块可以从这些场景开始:

场景建议的覆盖方式需要验证什么
注册成功E2E返回约定字段,用户可以继续登录,不泄露密码或密码哈希
邮箱格式无效E2E 保留代表用例,其余放到校验测试HTTP 请求确实经过 ValidationPipe,无效输入被拒绝
邮箱重复E2E + 数据库集成测试接口返回约定错误,数据库唯一约束有效
登录成功E2E返回可用的认证凭据,或建立有效会话
密码错误、账号不存在单元测试 + 必要的 E2E验证失败,错误响应符合约定
未登录访问受保护接口E2E请求被拒绝
已登录但权限不足E2EGuard 和权限配置实际生效
密码长度、字符组合等边界单元测试或 DTO 校验测试为主各条规则和边界值符合业务要求
数据库异常、外部服务超时单元测试或针对性的集成测试为主错误处理和重试行为正确

不同层的测试可以有少量重叠。例如,单元测试验证发现重复邮箱后是否抛出异常,E2E 则验证真实 HTTP 请求最终是否得到约定的错误响应。

新增 E2E 用例时,可以考虑:

  • 这个行为失败后,会不会阻断主要流程、造成数据错误或权限问题?
  • 它是否涉及多个组件的配合,例如 DTO、Pipe、Service、数据库和 Guard?
  • 现有低层测试能否发现同一种错误?

注册、登录和权限控制通常应优先覆盖。由同一条校验规则处理的大量输入变体,可以用参数化的低层测试补充,不必让每个字段的每种错误组合都走一次完整应用。

这段测试已经覆盖了哪些内容

通过 createNestApplication() 启动应用,再用 request(app.getHttpServer()) 发起请求,是 NestJS 中常见的接口 E2E 写法。NestJS 测试文档也展示了这种方式。

实际覆盖范围取决于测试加载了哪些真实组件:

  • 使用真实 Service 和测试数据库,可以覆盖从 HTTP 到数据库的后端流程。
  • 如果通过 overrideProvider() 替换了 Service,就不会覆盖该 Service 内部的业务逻辑和数据访问。
  • 在测试进程内创建应用,不会自动验证部署后的反向代理、TLS、网关和环境配置。这些需要通过部署后的冒烟测试补充。

文件名包含 e2e 并不能说明覆盖范围,判断时还要看请求实际经过的调用链。

现有代码应该怎样调整

让每个测试自己准备前置数据

当前登录测试和重复注册测试都依赖第一个测试创建的用户。单独运行登录测试时,用户可能不存在;如果注册测试失败,也会连带影响后续测试,让失败原因难以区分。

登录测试应自行创建用户,再尝试登录。重复注册测试则应在自己的用例里先注册一次,然后重复注册。

如果要验证“注册后立即登录”的完整用户流程,可以把两个请求放在同一个 it 中。一个业务场景可以包含多个请求。

让测试启动配置与实际应用一致

createNestApplication() 不会自动执行 main.ts 中的启动代码。如果 main.ts 配置了全局前缀、ValidationPipe、异常过滤器或序列化拦截器,测试也需要应用相同配置。

例如,生产环境使用:

new ValidationPipe({
  whitelist: true,
  transform: true,
})

而测试只使用 new ValidationPipe(),两者处理请求的行为就可能不同。

可以把公共配置提取为函数,由正式启动入口和测试共同调用。测试数据库地址、外部服务替身等环境配置则单独提供。

按本次测试的数据精确清理

原来的清理语句:

DELETE FROM "user" WHERE email LIKE 'test_%'

存在两个问题:

  • SQL 的 LIKE 中,_ 表示任意单个字符,并非字面量下划线。
  • 即使正确转义,也可能删除其他并行测试使用的数据。

应记录当前测试实际使用的用户 ID 或邮箱,再通过参数化查询精确删除。测试还应使用专用数据库;并行执行时,可以按 worker 分配独立数据库或 schema。

在测试里开启事务、最后回滚,能否隔离 HTTP 请求,取决于请求是否共享同一事务上下文。应用处理请求时可能使用其他数据库连接,因此需要先确认这一点。

一个保持用例独立的示例

示例沿用题目中的接口契约:注册和登录成功返回 201,重复邮箱返回 400,登录响应包含 success。这些值应以项目实际约定为准。

示例假设使用 PostgreSQL,用户表为 "user",存在 email 字段,且测试数据库配置已在启动测试前加载。如果用户关联了其他表,需要相应调整清理逻辑。

import { randomUUID } from 'node:crypto'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import request from 'supertest'
import { DataSource } from 'typeorm'
import { AppModule } from '../src/app.module'

describe('Auth (e2e)', () => {
  let app: INestApplication
  let dataSource: DataSource
  let testEmail: string

  const testPassword = 'TestPassword123'
  const emailsToClean = new Set<string>()

  const register = (email: string) => {
    // 请求失败时也保留记录,便于清理可能产生的数据。
    emailsToClean.add(email)

    return request(app.getHttpServer())
      .post('/auth/register')
      .send({ email, password: testPassword })
  }

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

    app = moduleFixture.createNestApplication()

    // 与正式应用保持一致;这里沿用题目中的配置。
    app.useGlobalPipes(new ValidationPipe())

    await app.init()
    dataSource = app.get(DataSource)
  })

  beforeEach(() => {
    testEmail = `e2e_${randomUUID()}@example.com`
  })

  afterEach(async () => {
    if (emailsToClean.size === 0) {
      return
    }

    await dataSource.query(
      'DELETE FROM "user" WHERE email = ANY($1::text[])',
      [[...emailsToClean]],
    )

    emailsToClean.clear()
  })

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

  it('registers a user without exposing credentials', async () => {
    const res = await register(testEmail).expect(201)

    expect(res.body).toEqual(
      expect.objectContaining({
        email: testEmail,
        id: expect.anything(),
      }),
    )
    expect(res.body).not.toHaveProperty('password')
    expect(res.body).not.toHaveProperty('passwordHash')
  })

  it('rejects an invalid email', async () => {
    const invalidEmail = `invalid_${randomUUID()}`
    const res = await register(invalidEmail).expect(400)

    expect(res.body.statusCode).toBe(400)
    expect(res.body.message).toEqual(
      expect.arrayContaining(['email must be an email']),
    )
  })

  it('logs in an existing user', async () => {
    await register(testEmail).expect(201)

    const res = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email: testEmail, password: testPassword })
      .expect(201)

    expect(res.body.success).toBe(true)
  })

  it('rejects a duplicate email', async () => {
    await register(testEmail).expect(201)

    const res = await register(testEmail).expect(400)

    expect(res.body.statusCode).toBe(400)
    expect(res.body.message).toBe('Email already exists')
  })
})

这个示例使用了共享的用例状态,适用于普通顺序执行的 Jest 用例,不应直接改成 it.concurrent。

登录测试只检查 success: true,还不足以确认凭据可用。如果接口返回 JWT,建议继续携带该 token 请求一个真实的受保护接口;如果使用 Cookie 会话,可以用 request.agent() 保留 Cookie。这样可以发现接口报告登录成功、凭据却无法使用的问题。具体路径和字段需要按项目实现填写。

状态码和错误文案应该断言到什么程度

状态码应明确断言。题目中登录返回 201、重复邮箱返回 400,按这个约定测试即可。如果接口设计选择登录返回 200、重复邮箱返回 409,则同步修改实现、接口文档和测试。

错误响应应断言到什么程度,取决于接口契约:

  • 前端依赖固定的业务错误码时,应精确验证错误码。
  • 错误文案本身属于对外约定时,可以精确验证文案。
  • 文案只是展示信息时,优先验证错误类别、字段和响应结构,减少因标点或措辞变化造成的测试失败。

原代码的 toStrictEqual(["email must be an email"]) 要求消息数组完全相同,因此新增一条合法的校验规则也可能让测试失败。示例使用 arrayContaining() 检查该错误,同时允许出现其他校验消息。

还应检查重要副作用。例如,重复注册后,数据库中仍然只有一条对应的用户记录;验证失败后,没有创建用户。这些检查比反复断言每个错误响应都包含 "Bad Request" 更能发现业务问题。

单元测试和集成测试补在哪里

认证 Service 的单元测试可以使用 Repository、密码校验组件和 token 签发组件的替身,分别验证:

  • 邮箱已存在时拒绝注册,不继续保存用户。
  • 注册时传给持久化层的是密码哈希。
  • 密码校验失败时不签发认证凭据。
  • 账号不存在时按约定返回认证错误。

真实数据库上的集成测试用于验证唯一约束、查询条件和事务行为。例如,“先查询邮箱不存在,再插入用户”无法单独防止并发重复注册,仍需数据库唯一约束兜底。测试也应验证约束冲突能否转换成约定的接口错误。

测试数据库的类型也应尽量与生产一致。如果生产使用 PostgreSQL,测试使用 SQLite,就不能据此认定 PostgreSQL 的约束和 SQL 行为已经得到验证。

可以参考哪些仓库

NestJS 官方仓库的 sample/01-cats-app包含两类测试,可以对照阅读:

  • cats.controller.spec.ts:直接调用 Controller,适合参考测试模块初始化和断言写法。
  • cats.e2e-spec.ts:启动应用并发送 HTTP 请求,但替换了 CatsService,覆盖范围不包含真实业务 Service 和数据库。

实际项目可以先保留现有四个场景,补上错误密码、未登录访问和权限不足等认证边界,再把字段校验的各种输入组合放到更快的低层测试中。每次修复缺陷时,在能够稳定复现问题的测试层增加回归用例。

备注:内容仅供参考。