NestJS 后端的 E2E 测试应该如何编写,覆盖范围如何确定?
注册成功、邮箱无效、登录成功和重复注册这几个场景值得保留,但无需为后端所有可能的返回结果都编写 E2E 测试。可以用 E2E 验证关键业务流程和对外接口契约,把细致的业务分支交给单元测试,再通过集成测试验证数据库等组件的真实行为。
现有代码需要优先解决测试之间的数据依赖,并调整测试环境配置和数据清理方式。
如何确定 E2E 测试的覆盖范围
先列出业务必须保证的行为,再决定由哪一层测试负责。认证模块可以从这些场景开始:
| 场景 | 建议的覆盖方式 | 需要验证什么 |
|---|---|---|
| 注册成功 | E2E | 返回约定字段,用户可以继续登录,不泄露密码或密码哈希 |
| 邮箱格式无效 | E2E 保留代表用例,其余放到校验测试 | HTTP 请求确实经过 ValidationPipe,无效输入被拒绝 |
| 邮箱重复 | E2E + 数据库集成测试 | 接口返回约定错误,数据库唯一约束有效 |
| 登录成功 | E2E | 返回可用的认证凭据,或建立有效会话 |
| 密码错误、账号不存在 | 单元测试 + 必要的 E2E | 验证失败,错误响应符合约定 |
| 未登录访问受保护接口 | E2E | 请求被拒绝 |
| 已登录但权限不足 | E2E | Guard 和权限配置实际生效 |
| 密码长度、字符组合等边界 | 单元测试或 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 和数据库。
实际项目可以先保留现有四个场景,补上错误密码、未登录访问和权限不足等认证边界,再把字段校验的各种输入组合放到更快的低层测试中。每次修复缺陷时,在能够稳定复现问题的测试层增加回归用例。
备注:内容仅供参考。
