PAYATHON 2026

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
}

为什么第二个测试更容易暴露问题

第二组测试会实际发出两个请求:

  1. beforeEach 先创建一条 credit;
  2. 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 串行执行。

备注:内容仅供参考。