Jest 测试 Koa route 时出现 timeout 且无法退出
结论
server.close() 只会停止 HTTP Server,不会关闭 Knex 创建的数据库连接池。测试结束后,如果 PostgreSQL 连接、未完成的事务或其他异步句柄仍然存在,Jest 就无法退出。
添加第二组测试后出现 timeout,问题通常不在 describe 的数量,而是新增请求触发了以下情况:
- route 获取的数据库连接或事务没有释放;
- Knex 连接池耗尽,后续请求一直等待可用连接;
- 数据库查询被锁,或者事务没有提交;
beforeEach中的请求超过了 Jest 默认的 5000ms;- 请求进入某条业务分支后没有正常结束。
所以,除了检查请求处理逻辑,还要在 teardown 中关闭所有 Knex 实例。
正确关闭 Knex 和 HTTP Server
测试文件创建了一个 Knex 实例:
const knex = require('knex')(config.development)
测试结束后必须调用:
await knex.destroy()
调用 server.close() 时也应该等待关闭完成,不能刚调用就结束 afterAll:
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close(error => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
完整的 teardown 可以这样写:
afterAll(async () => {
await knex.raw(`
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO public;
`)
await closeServer(server)
await knex.destroy()
})
如果应用内部还创建了另一个 Knex 实例,同样需要关闭。例如:
// db.js
const knex = require('knex')(config.development)
module.exports = knex
测试中应该复用并销毁这个实例:
const db = require('../db')
afterAll(async () => {
await closeServer(server)
await db.destroy()
})
不要在 route、service 和测试文件里分别调用 require('knex')(config),否则会产生多个难以统一管理的连接池。
重点检查事务是否完整结束
如果 /v1/hampers 使用了 Knex transaction,要确保成功时提交,失败时回滚。下面这种写法很容易泄漏连接,因为代码取得 transaction 后,可能在某个分支直接返回:
const trx = await knex.transaction()
const credit = await trx('credits')
.where({ user_id: userId })
.first()
if (!credit) {
ctx.status = 402
return
}
这里的 return 没有提交或回滚事务,连接会一直被占用。请求多起来后,连接池就可能耗尽。
更稳妥的做法是使用 transaction callback:
await knex.transaction(async trx => {
const credit = await trx('credits')
.where({ user_id: userId })
.first()
if (!credit) {
ctx.status = 402
return
}
await trx('hampers').insert({
user_id: userId,
amount: -200
})
ctx.status = 200
})
callback 正常执行完毕时,Knex 会提交事务;如果抛出异常,则会回滚。必须手动管理 transaction 时,每一条执行路径都要调用 commit() 或 rollback():
const trx = await knex.transaction()
try {
await trx('hampers').insert(data)
await trx.commit()
} catch (error) {
await trx.rollback()
throw error
}
为什么第二个测试更容易暴露问题
第二组测试会实际发出两个请求:
beforeEach先创建一条 credit;it再创建 utility hamper。
算上第一组测试,总共会执行三个请求。如果每个请求都泄漏一个数据库连接,前几个请求可能还能正常返回,后面的请求则会卡在等待连接的位置。Supertest 收不到响应,最后由 Jest 报错:
Timeout - Async callback was not invoked within the 5000ms timeout
specified by jest.setTimeout.
这个 timeout 说明某个异步操作没有在规定时间内完成,并不代表 Jest 的 async/await 写法有问题。要进一步确认超时发生在 beforeAll、beforeEach、测试请求还是 afterAll。
可以临时输出各阶段的信息,也可以运行:
jest --runInBand --detectOpenHandles
--detectOpenHandles 可以帮助定位残留的 Server、TCP socket、数据库连接和 timer。它只适合诊断,不能代替正确的资源释放。
更适合 Koa 测试的结构
如果测试不需要绑定真实端口,可以导出 Koa app,让 Supertest 直接测试 app.callback():
// app.js
const Koa = require('koa')
const app = new Koa()
// middleware and routes
module.exports = app
生产入口再单独启动监听:
// server.js
const app = require('./app')
const server = app.listen(process.env.PORT || 3000)
module.exports = server
测试中不需要启动真实的 HTTP Server:
const request = require('supertest')
const app = require('../app')
const knex = require('../db')
afterAll(async () => {
await knex.destroy()
})
it('should reject a payment for a user without credit', async () => {
const response = await request(app.callback())
.post('/v1/hampers')
.set('Content-Type', 'application/json')
.send({
entity_type: 'utility',
latitude: 1,
longitude: 1,
comment: null,
user_id: Date.now(),
amount: -200,
entity_id: 1,
processed: false
})
expect(response.status).toBe(402)
})
采用这种结构后不需要调用 server.close(),但数据库连接池仍然要通过 destroy() 关闭。
不要仅靠增加 timeout 掩盖问题
数据库初始化和 migration 确实可能超过 5 秒。确认没有连接泄漏、锁等待和未结束的事务后,可以适当延长超时时间:
jest.setTimeout(30000)
也可以只为较慢的 hook 设置超时:
beforeAll(async () => {
const file = fs.readFileSync(
path.join(__dirname, '..', 'galaxycard.sql'),
'utf8'
)
await knex.raw(file)
await knex.migrate.latest()
}, 30000)
如果请求一直在等待连接,增加 timeout 只会延后失败时间。
同一个 hook 中也不要同时使用 async 和 done:
// 不推荐
afterAll(async done => {
await knex.destroy()
done()
})
使用 Promise/async 或 done 中的一种即可。
最后,DROP SCHEMA public CASCADE 是破坏性操作。如果多个 Jest worker 或其他进程共用同一个测试数据库,它们可能互相阻塞,甚至删除对方的数据。这类测试应该使用独立的测试数据库,并根据实际情况通过 --runInBand 串行执行。
备注:内容仅供参考。
