在构建 Web 服务时,单元测试通常针对工具函数、服务层逻辑等纯代码模块,但最终暴露给外部的 HTTP 接口才是系统最直接的功能入口。接口测试通过模拟真实的 HTTP 请求,验证路由处理、中间件、参数校验、响应格式以及业务逻辑的端到端行为,是保障 API 质量的最后一道防线。在 Node.js 生态中,supertest 是最常用的 HTTP 测试工具,它可以与任意测试框架(Jest、Mocha、Vitest 等)无缝集成,以极简的链式语法完成接口的自动化验证。
18.2.1 supertest 的定位与原理
supertest 是一个测试友好的 HTTP 断言库,它封装了 Node.js 原生的 http 模块,能够启动一个临时 HTTP 服务器并绑定你的 Express/Koa/NestJS 等应用实例,然后发起真实的 HTTP 请求。请求不需要通过真实网络栈,而是在进程内部直接传递,因此速度极快,且不会占用系统端口。
核心工作流程:
- 在测试文件中引入应用实例(例如 Express 的
app对象)。 - 使用
request(app)创建一个请求会话,该会话内部会启动一个临时服务器并监听随机端口。 - 通过链式调用配置请求方法、路径、请求头、请求体等。
- 使用
.expect()或.end()进行断言,检查状态码、响应头、响应体是否符合预期。 - 每次
request(app)会独立创建连接,确保测试之间互不干扰。
18.2.2 安装与测试框架集成
安装 supertest 通常作为开发依赖:
npm install --save-dev supertest
supertest 不强制绑定特定测试框架,但实际使用中几乎都会配合 Jest、Mocha 等。以 Jest 为例,一个典型的接口测试文件结构如下:
// app.test.js
const request = require('supertest');
const app = require('../app'); // Express 应用实例
describe('User API', () => {
test('GET /users 应返回用户列表', async () => {
const res = await request(app)
.get('/users')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
});
});
如果你的应用使用 Koa,只需传入 app.callback() 即可;如果使用 NestJS,则可以在 beforeAll 中初始化 TestingModule 并获取 app.getHttpServer() 传给 supertest。
18.2.3 基本请求与链式断言
supertest 提供了一套流畅的链式 API,覆盖了所有 HTTP 方法:
get(path),post(path),put(path),patch(path),delete(path)- 设置请求头:
set('Authorization', 'Bearer xxx')或set({ 'x-custom': 'value' }) - 发送请求体:
send({ username: 'foo', password: 'bar' }) - 附加查询参数:
query({ page: 1, size: 20 }) - 字段上传(模拟表单):
field('name', 'avatar')和attach('file', path.join(__dirname, 'test.png')) - 设置 Cookie:
set('Cookie', 'token=abc123') - 期望状态码:
expect(200) - 期望响应头:
expect('Content-Type', /json/) - 对响应体进行断言:
expect({ status: 'success' })(精确匹配)、expect({ status: 'success' })或使用回调函数expect(res => { ... })
expect() 既可以接收状态码,也可以接收对象/正则,还可以接收一个回调函数对响应对象进行自定义断言。通常我们会混合使用,例如:
const res = await request(app)
.post('/login')
.send({ email: 'test@example.com', password: 'secret' })
.expect(201)
.expect('Content-Type', /json/);
// 进一步断言响应体
expect(res.body).toHaveProperty('token');
expect(res.body.user.email).toBe('test@example.com');
注意:如果使用 async/await 方式,expect 返回的是响应对象(除非你用回调抛错),可以继续用全功能断言库(如 Jest 的 expect)来验证任何细节。
18.2.4 处理数据库与外部依赖的清理
接口测试通常会与真实数据库交互,为了避免测试间数据污染,需要在每个测试用例前后进行数据准备与清理。推荐做法:
- 使用独立的测试数据库:在环境变量中配置单独的数据库连接,每次测试套件启动时执行迁移/重置脚本。
- 在每个测试文件或测试用例前清空相关表:可以利用
beforeAll、afterAll钩子执行 truncate 或 delete 操作。 - 利用事务回滚:例如在 Prisma 中,可以开启交互式事务,测试结束时回滚。但这种方案实施较复杂,更常用的还是快速清空。
以 Jest 为例:
const { execSync } = require('child_process');
beforeAll(async () => {
// 迁移测试数据库到最新
execSync('npx prisma migrate deploy', { env: { ...process.env, DATABASE_URL: 'mysql://test_db...' } });
});
afterEach(async () => {
// 删除测试产生数据(根据业务调整)
await prisma.user.deleteMany();
await prisma.post.deleteMany();
});
如果项目使用 ORM(如 Sequelize、TypeORM),同样可以在 beforeEach 中执行 sync({ force: true }) 或清理特定表。要注意的是,清空操作本身是异步的,必须配合 await 或返回 Promise。
有些项目会使用内存数据库(如 sql.js、mongodb-memory-server)来加速测试,这种方式可以省略数据清理步骤,因为常驻内存不会持久化。但对于复杂查询、存储过程等,内存数据库可能存在表现不一致的问题,需要评估。
18.2.5 认证与权限场景的测试
受保护的接口需要携带认证令牌。一种常见的做法是在 beforeAll 中先通过登录接口获取 token,然后存储到变量中,后续测试复用:
let authToken;
beforeAll(async () => {
const res = await request(app)
.post('/login')
.send({ email: 'admin@test.com', password: 'admin' })
.expect(201);
authToken = res.body.token;
});
test('GET /admin/users 需要管理员权限', async () => {
await request(app)
.get('/admin/users')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
});
test('缺少 token 应返回 401', async () => {
await request(app)
.get('/admin/users')
.expect(401);
});
对于不需要真实鉴权的单元测试,也可以使用 mock 或直接注入测试用户到中间件,但这会偏离接口端到端测试的初衷。建议在接口测试中使用完整的登录逻辑,以确保认证中间件行为正确。
18.2.6 处理异步接口与流式响应
对于耗时操作(如文件导出、数据库聚合计算),接口可能会需要较长处理时间。supertest 默认的超时时间为 5000ms,可以在测试用例级别增加 Jest 的超时设置:
test('导出大文件', async () => {
const res = await request(app)
.post('/export')
.send({ format: 'csv' })
.expect(200)
.timeout(10000); // 增加 supertest 超时
}, 15000); // 增加 Jest 测试用例超时
如果你的接口返回流(如 pdf 生成),response 的 body 会是 Buffer,可以使用 expect(res => { expect(res.body).toBeInstanceOf(Buffer); }) 来验证,甚至检查文件内容。
18.2.7 常见测试模式与组织建议
在实际项目中,接口测试通常按模块分组,每个资源对应一个测试文件。测试用例应覆盖正面情况、异常输入、边界值和权限场景:
// users.test.js
describe('POST /users', () => {
test('创建用户成功', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'John', email: 'john@test.com', password: '123456' })
.expect(201);
expect(res.body).toMatchObject({ name: 'John', email: 'john@test.com' });
});
test('缺少必填字段返回400', async () => {
await request(app)
.post('/users')
.send({ name: 'John' }) // 缺少 email 和 password
.expect(400);
});
test('重复邮箱返回409', async () => {
// 先创建用户
await request(app)
.post('/users')
.send({ name: 'A', email: 'dup@test.com', password: '123456' });
// 再次创建相同邮箱
const res = await request(app)
.post('/users')
.send({ name: 'B', email: 'dup@test.com', password: '654321' })
.expect(409);
expect(res.body.message).toContain('already exists');
});
});
这种划分让每个用例职责单一,失败时能快速定位问题。同时,尽量保持测试的独立性:每个用例不依赖其他用例的执行顺序,数据状态由 beforeEach 或 afterEach 保证一致性。
18.2.8 接口测试与单元测试的分工
需要明确的是,接口测试并非单元测试的替代。单元测试验证函数行为,运行极快;接口测试验证 HTTP 层的集成行为,相对较慢。我们应遵循测试金字塔原则:
- 大部分逻辑使用单元测试覆盖(服务层、工具函数)。
- 少量核心接口用接口测试覆盖(路由、中间件、参数校验、主要业务流程)。
- 端到端测试(E2E)覆盖最高层用户交互(如用 Playwright 操作浏览器),数量更少。
supertest 的优势就在于它让接口测试的编写成本接近于单元测试,从而可以更早地发现 API 的回归错误。结合 CI 流水线,每次提交自动运行接口测试,可以有效防止接口契约被意外破坏。
掌握了 supertest 接口测试的基本用法后,我们在下一节将进一步学习集成测试与 E2E 测试的实践,以及如何在复杂的微服务架构中组织多服务的测试策略。