5月28日 04:30

Koa 怎么写测试?从框架选型到中间件和 API 踩坑实录

Koa 项目写测试,很多人第一反应是"随便装个 Jest 就完事了"。但真正上手之后才会发现:中间件的洋葱模型怎么测?数据库操作怎么隔离?Supertest 和 Koa 的 callback 模式怎么配合才不会内存泄漏?这篇文章把框架选型的思路和实际项目中最容易踩的坑一次讲清。

Jest 还是 Mocha?先想清楚再选

选测试框架不需要纠结太久,关键是看你的项目阶段和团队习惯。

Jest 的优势:零配置开箱即用,内置断言、mock、覆盖率报告。测试并行执行,速度快。遇到问题时错误信息比 Mocha 友好得多,直接告诉你哪个断言失败、期望值和实际值分别是什么。

Mocha 的优势:灵活性高,断言库可以选 Chai、Should、Expect,mock 可以选 Sinon 或自己写。对于已有 Mocha 体系的存量项目,迁移成本为零。

实际经验:新项目直接用 Jest,别犹豫。Mocha 需要额外配 Chai + Sinon + Istanbul,搭环境的时间够你写十几个测试用例了。唯一需要考虑 Mocha 的场景是,你的 CI 环境内存特别紧张——Jest 并行执行会吃更多内存,Mocha 串行跑更稳。

安装和基础配置

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

Supertest 是测试 Koa HTTP 接口的核心工具,它不需要真正启动服务器,直接调用 app.callback() 生成请求处理函数,避免了端口占用和进程管理的问题。

javascript
// jest.config.js module.exports = { testEnvironment: 'node', coverageDirectory: 'coverage', collectCoverageFrom: [ 'src/**/*.js', '!src/**/*.test.js' ], testMatch: [ '**/__tests__/**/*.js', '**/?(*.)+(spec|test).js' ] };

一个容易忽略的配置:如果你的项目用了 Babel 或 TypeScript,需要额外配 transform 字段,否则 Jest 无法识别 ES Module 的 import 语法。

路由测试:最基础也最容易出错

javascript
const request = require('supertest'); const app = require('../app'); describe('Basic routes', () => { test('GET / should return Hello Koa', async () => { const response = await request(app.callback()) .get('/') .expect(200); expect(response.text).toBe('Hello Koa'); }); test('GET /not-found should return 404', async () => { await request(app.callback()) .get('/not-found') .expect(404); }); });

踩坑点app.callback() 而不是 app.listen()。用 listen() 会在每个测试文件启动一个新的 HTTP 服务器,Jest 跑完不关的话,进程不会退出,CI 直接卡住。callback() 返回的是一个标准的 Node.js request handler,Supertest 内部会自己创建临时服务器并自动关闭,不会有泄漏问题。

中间件测试:洋葱模型的坑

Koa 中间件是洋葱模型——请求从外层进去,响应从内层出来。测试中间件时最常见的错误是手动构造 ctx 对象:

javascript
// 错误写法:手动造 ctx const ctx = { headers: {}, state: {}, throw: jest.fn() }; await middleware(ctx, next);

这种写法绕过了 Koa 的上下文封装,ctx 上少一堆属性和方法(ctx.requestctx.responsectx.set() 等),测试结果和生产环境完全不一致。某个中间件在测试里通过了,上了线照样炸。

正确写法:创建一个最小的 Koa 实例,把中间件挂上去,用 Supertest 发请求:

javascript
const Koa = require('koa'); const authMiddleware = require('../middleware/auth'); describe('Auth middleware', () => { test('should allow access with valid token', async () => { const app = new Koa(); app.use(authMiddleware); app.use(async (ctx) => { ctx.body = { user: ctx.state.user }; }); const response = await request(app.callback()) .get('/test') .set('Authorization', 'Bearer valid-token') .expect(200); expect(response.body.user).toBeDefined(); }); test('should deny access without token', async () => { const app = new Koa(); app.use(authMiddleware); app.use(async (ctx) => { ctx.body = 'ok'; }); await request(app.callback()) .get('/test') .expect(401); }); });

这样测试走的是完整的 Koa 请求生命周期,中间件拿到的 ctx 和生产环境一模一样。

还有一个容易忽略的问题:中间件里 await next() 前后的代码分别对应请求进入和响应返回两个阶段。如果你的中间件在 await next() 之后做了什么操作(比如记录响应时间、修改响应头),测试时必须验证响应结果而不仅仅是 next() 是否被调用。

数据库测试:隔离是第一优先级

数据库相关的测试最容易污染环境。几个关键原则:

  1. 用独立的测试数据库。永远不要在开发库里跑测试,knexsequelize 配置里加一个 test 环境指向独立库。
  2. 每个测试用例前清空数据。用 beforeEach + truncateafterEach 更安全——如果测试中途挂了,afterEach 可能没执行,脏数据就留下了。
  3. 事务回滚是个好办法,但有陷阱。把每个测试包在一个事务里,跑完回滚,这样数据库始终保持干净。但如果你用了多进程并发测试(Jest 默认行为),不同 worker 的事务可能互相看到未提交的数据,取决于数据库的隔离级别。PostgreSQL 默认的 Read Committed 读不到其他事务未提交的数据,问题不大;MySQL 的某些引擎就不是这样了。
javascript
describe('User API with DB', () => { let db; beforeAll(async () => { db = require('../models'); await db.sequelize.sync({ force: true }); }); afterAll(async () => { await db.sequelize.close(); }); beforeEach(async () => { await db.User.destroy({ truncate: true, cascade: true }); }); test('POST /api/users should create user', async () => { const userData = { name: 'John Doe', email: 'john@example.com', password: 'password123' }; const response = await request(app.callback()) .post('/api/users') .send(userData) .expect(201); expect(response.body).toHaveProperty('id'); expect(response.body).not.toHaveProperty('password'); }); });

password 不应该出现在响应里——这种断言看似简单,但能抓住最常见的安全漏洞。如果你的 API 返回了密码字段(即使是哈希),这个测试会立刻暴露出来。

Mock 策略:别过度 Mock

Mock 是双刃剑。Mock 太多,测试变成了"验证我的 mock 逻辑",和真实代码没有任何关系;Mock 太少,测试依赖外部服务,CI 随时可能因为第三方 API 超时而失败。

原则:只 Mock 跨越边界的调用——外部 API、文件系统、邮件发送。内部函数调用不要 Mock,否则你测的不是代码逻辑,而是你对代码逻辑的假设。

javascript
// 合理的 Mock:模拟第三方 API const nock = require('nock'); test('should fetch user from external service', async () => { nock('https://api.example.com') .get('/users/1') .reply(200, { id: 1, name: 'Test User' }); const response = await request(app.callback()) .get('/api/external-users/1') .expect(200); expect(response.body.name).toBe('Test User'); nock.cleanAll(); }); // 不推荐的 Mock:模拟内部数据库调用 // jest.spyOn(User, 'findById').mockResolvedValue({ id: 1 }); // 这样测的是 mock 的返回值,不是数据库查询逻辑

文件上传测试

文件上传是另一个容易遗漏的测试场景。Supertest 支持 .attach().field() 方法来模拟 multipart/form-data 请求:

javascript
const path = require('path'); describe('File upload', () => { test('should upload avatar and return URL', async () => { const response = await request(app.callback()) .post('/api/upload/avatar') .set('Authorization', `Bearer ${authToken}`) .attach('avatar', path.join(__dirname, 'fixtures/test-avatar.png')) .expect(200); expect(response.body).toHaveProperty('url'); expect(response.body.url).toMatch(/^https?:\/\//); }); test('should reject files over size limit', async () => { await request(app.callback()) .post('/api/upload/avatar') .set('Authorization', `Bearer ${authToken}`) .attach('avatar', path.join(__dirname, 'fixtures/large-file.png')) .expect(413); }); });

踩坑提醒:测试用的文件放在 __tests__/fixtures/ 目录下,别用线上真实用户上传的文件——一是隐私问题,二是文件可能随时被删导致测试莫名失败。

错误处理测试

错误处理是最容易遗漏的测试场景,但恰恰是线上出问题时最需要保障的部分:

javascript
describe('Error handling', () => { test('should handle validation errors with 400', async () => { const response = await request(app.callback()) .post('/api/users') .send({ name: '', email: 'invalid' }) .expect(400); expect(response.body).toHaveProperty('code', 'VALIDATION_ERROR'); }); test('should handle unexpected errors with 500', async () => { // 模拟数据库异常 jest.spyOn(User, 'create').mockRejectedValue(new Error('DB connection lost')); const response = await request(app.callback()) .post('/api/users') .send({ name: 'Test', email: 'test@example.com' }) .expect(500); expect(response.body).toHaveProperty('code', 'INTERNAL_ERROR'); }); });

一个真实教训:某次部署后,数据库连接池耗尽导致所有 API 返回 500,但前端只显示"网络错误"。如果提前测了 500 的响应格式,前端至少能给用户一个像样的提示。

集成测试:串起完整流程

单元测试验证单个函数,集成测试验证整个流程能跑通。关键是在 beforeAll 里完成前置操作,afterAll 里清理数据:

javascript
describe('Full user flow', () => { let authToken; beforeAll(async () => { const response = await request(app.callback()) .post('/api/auth/login') .send({ email: 'test@example.com', password: 'password123' }); authToken = response.body.token; }); afterAll(async () => { await request(app.callback()) .delete('/api/test/cleanup') .set('Authorization', `Bearer ${authToken}`); }); test('should create and retrieve a post', async () => { const createRes = await request(app.callback()) .post('/api/posts') .set('Authorization', `Bearer ${authToken}`) .send({ title: 'Test Post', content: 'Hello' }) .expect(201); const postId = createRes.body.id; const getRes = await request(app.callback()) .get(`/api/posts/${postId}`) .set('Authorization', `Bearer ${authToken}`) .expect(200); expect(getRes.body.title).toBe('Test Post'); }); });

集成测试不需要覆盖所有边界情况,那是单元测试的活。集成测试的价值在于确认"登录 -> 创建 -> 查询 -> 删除"这条主线不断。

CI 里的测试配置

测试写好了,CI 里跑不起来是最让人崩溃的。几个常见问题:

  • Jest 超时:数据库操作默认 5 秒超时不够用,在测试文件顶部加 jest.setTimeout(30000) 或在配置里统一设置。
  • 端口冲突:如果某个测试用了 app.listen() 而不是 app.callback(),并行执行时端口会被占。全局搜一下 listen,确保测试里没有直接调用。
  • 数据库连接不关闭afterAll 里必须 sequelize.close()mongoose.disconnect(),否则 Jest 进程挂起不退出。
  • 环境变量:CI 里用 cross-env NODE_ENV=test jest,确保代码里读到的数据库配置是测试库而不是生产库。

覆盖率方面,80% 是底线,但别追求 100%——有些代码(如启动脚本、配置文件)写测试纯属浪费时间。重点关注业务逻辑层和控制器的覆盖率。在 jest.config.js 里设置 coverageThreshold,低于阈值直接让 CI 失败:

javascript
coverageThreshold: { global: { branches: 70, functions: 80, lines: 80, statements: 80 } }

测试数据管理

维护测试数据最省心的方式是用工厂函数,比硬编码 fixture 灵活,比每次手写对象不容易遗漏字段:

javascript
const userFactory = (overrides = {}) => ({ name: 'Test User', email: `test${Date.now()}@example.com`, password: 'password123', ...overrides }); test('should create user with custom name', async () => { const userData = userFactory({ name: 'Custom Name' }); const response = await request(app.callback()) .post('/api/users') .send(userData) .expect(201); expect(response.body.name).toBe('Custom Name'); });

工厂函数里 email 加了时间戳后缀,避免并发测试时邮箱唯一约束冲突。这个小技巧省了无数调试时间。

写测试这件事,起步觉得麻烦,写顺手了你会发现:改代码的胆子大了很多,部署前不再心虚,凌晨三点的报警也少了。框架选型五分钟搞定,踩坑排查才花时间——把坑提前在测试里踩掉,比在线上踩便宜太多了。

标签:Koa