Vitest 可取消测试资源:用 test context 的 signal 在超时、bail 与 Ctrl+C 时释放资源
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
测试代码经常持有"不会自己停下来"的资源:一个fetch请求、一个子进程、一个文件流、一个轮询循环——当 Vitest 取消测试时,这些资源并不会察觉,worker 只能干等它们自己结束。Vitest 会在三种场景下取消测试:测试超过timeout、--bail模式下另一个测试失败、或者你在终端按下了 Ctrl+C。从 Vitest 3.2.0 起,test context 提供了一个signal(AbortSignal),在上述所有场景中都会被 abort——把它传给任何接受AbortSignal的 API,资源就会随 Vitest 的取消动作一起被释放。本文完整讲解这一 recipe 的用法,并结合 Vitest 运行器源码说明 signal 的创建、abort 时机与取消传播链路。
哪些情况下 Vitest 会取消一个测试
先明确signal的触发条件。综合 recipe 与 Test Context 文档,以下四种情况都会让当前测试(及其并发兄弟测试)的signal进入 aborted 状态:
- 测试超时:测试运行时间超过单测
timeout参数或全局testTimeout配置; - 手动取消:用户在终端按 Ctrl+C;
- 程序化取消:通过
vitest.cancelCurrentRun显式取消整个测试运行; - bail 联动:在并行运行时另一个测试失败,且配置了
bail(默认1),后续未开始的测试会被取消。
不处理这些场景的常见后果:超时后 worker 仍在等一个永远不会 resolve 的fetch,测试文件挂死;轮询循环持续打满本地服务的端口;子进程泄漏。signal的价值就在于把"Vitest 决定不再等"这一决策同步传递给资源本身。
核心模式:把 signal 传给 fetch
recipe 给出的最小可用示例,将测试的signal直接传给fetch的AbortSignal选项:
import { test } from 'vitest' test('stop request when test times out', async ({ signal }) => { await fetch('/heavy-resource', { signal }) }, 2000)如果 2 秒内请求没有完成,fetch会以AbortError拒绝,而不是让测试一直挂到请求自然结束。测试会干净地以超时失败,worker 可以立刻进入下一个测试或退出。
其他接受 AbortSignal 的 Web / Node API
signal是一个标准的AbortSignal实例,因此凡是需要"外部取消"能力的平台 API 都可以直接接受它:
fetch:请求中途可被取消;addEventListener:传入{ signal }后,abort 时监听器会被自动移除,适合"订阅到取消为止"的测试;ReadableStream.pipeTo:管道传输可随取消中断;- Node.js API:
fs.readFile(fs/promises)、child_process.spawn,以及带{ signal }选项的setTimeout/setInterval——abort 时定时器会被清除; - 任何自行实现取消逻辑的代码:调用
signal.throwIfAborted()或监听'abort'事件。
这意味着同一个signal可以同时驱动请求、定时器和监听器,资源清理是"一次 abort、全部释放"。
转发 signal:让取消向自定义 helper 传播
真实项目里测试很少直接裸调fetch,更多时候是在自己封装的 helper 里做轮询、重试或等待。正确的做法是把测试的signal作为参数一路传下去,让取消能传播到最内层:
async function pollUntilReady(url: string, signal: AbortSignal) { while (!signal.aborted) { const res = await fetch(url, { signal }) if (res.ok) { return } await new Promise(r => setTimeout(r, 200)) } signal.throwIfAborted() } test('worker becomes ready', async ({ signal }) => { await pollUntilReady('http://localhost:4000/health', signal) }, 5000)这个示例里有三个值得注意的细节:
- 循环条件检查
signal.aborted:每次轮询前先问一次"还值得继续吗",避免无意义的最后一次请求; fetch(url, { signal })携带同一个 signal:单次请求被取消时fetch会 reject,Promise 会向上抛出;- 循环出口调用
signal.throwIfAborted():这是一个防御性收口——如果signal.aborted在最后一次循环判断后变为 true,这里会抛出 abort reason,保证测试函数一定以取消错误退出,而不是静默返回。
对于不原生支持AbortSignal的第三方客户端(例如某些 HTTP SDK),可以在 helper 内部订阅signal的'abort'事件并手动调用客户端的close()/destroy(),效果等价。
源码解析:Vitest 如何创建并 abort 这个 signal
recipe 讲的是"怎么用",运行器源码(packages/vitest/src/runtime/runner/)则解释了"为什么它能覆盖所有取消场景"。
每个测试上下文持有独立的 AbortController
在 context.ts 中,createTestContext会为每个测试创建并挂载一个AbortController,存放在TestContext与 controller 之间的WeakMap里:
const abortControllers = new WeakMap<TestContext, AbortController>() export function createTestContext(test, runner): TestContext { // ... let abortController = abortControllers.get(context) if (!abortController) { abortController = new AbortController() abortControllers.set(context, abortController) } context.signal = abortController.signal // ... }用WeakMap而非在 task 对象上加字段,可以推断是为了保持 task 元数据的简洁,同时让 controller 随 context 一起被 GC。
统一的 abort 入口是abortContextSignal:
export function abortContextSignal(context: TestContext, error: Error): void { const abortController = abortControllers.get(context) abortController?.abort(error) }注意abort(error)把超时错误作为abort reason传入——也就是说signal.reason里携带的就是那条超时错误,signal.throwIfAborted()抛出的也是它,而不是一个裸的AbortError。
测试函数的包装链:withTimeout → withCancel
suite.ts 中,每个测试 handler 在收集阶段就被层层包装:
withTimeout( withCancel(withAwaitAsyncAssertions(withFixtures(handler, { context }), task), task.context.signal), timeout, false, stackTraceError, (_, error) => abortIfTimeout([context], error), )调用链是:withTimeout起一个TaskDeadline计时器 → 超时触发onTimeout回调 →abortIfTimeout拿到测试 context →abortContextSignal(context, error)→AbortController.abort(超时错误)。随后两件事同时发生:
- 所有携带该
signal的 API(fetch、spawn、定时器)各自以取消结束,资源被释放; withCancel包装的 Promise 被 reject(见下),测试以取消错误失败。
withCancel的实现非常短:它在测试 Promise 上挂一个abort监听器,一旦 abort 就立即reject(signal.reason),并在 Promise 正常 settle 后移除监听器。这保证了超时之后 Promise 不会被"迟到的自然完成"抢先 resolve——先到先得,取消胜出。
同文件中的onTestFailed/onTestFinished钩子超时同样会走abortController.abort(error),即钩子本身超时也会取消整个测试的 signal。run.ts 中aroundEach钩子超时的onTimeout也调用abortContextSignal(test.context, error),取消语义覆盖了 fixture 与 hook 层。
手动取消与 bail 走的是另一条入口
- Ctrl+C:stdin.ts 监听键盘输入,调用
ctx.cancelCurrentRun('keyboard-input'); - 程序化取消:core.ts 中的
VitestNode.cancelCurrentRun(reason)负责向各 worker 池广播取消(pools/rpc.ts中也有对应的vitest.cancelCurrentRun(reason)调用)。
这些入口最终都汇聚到 worker 侧对每个运行中测试的abortContextSignal调用,因此无论是键盘、RPC 还是超时,测试代码看到的都只是同一个signal变成了 aborted。
仓库测试如何验证这些行为
e2e 信号测试 用内联测试逐一验证了上述链路,是很好的"预期行为"参照:
timeout aborts the signal without fixtures/timeout aborts the signal:10ms 超时的测试中监听signal的abort事件并写task.meta,断言 stderr 含Test timed out in 10ms.且meta为{ aborted: true };两个用例分别覆盖"未使用 fixture"和"通过test.extend强制初始化 fixture"两条路径;timeout aborts all signals in concurrent tests:test.concurrent.for([1,1,1])三个并发测试全部超时,三个 signal 均被 abort——说明超时取消不是"取消一个"而是覆盖并发组内所有测试;cancelling test run aborts the signal:自定义 Reporter 在检测到console.log('ready')后调用this.vitest.cancelCurrentRun('keyboard-input'),测试函数挂起的 Promise 被 abort 事件 resolve,验证了程序化取消链路。
小结
- 从Vitest 3.2.0起,
{ signal }是 test context 的内置成员,类型为AbortSignal; - 它会在测试超时、Ctrl+C、
cancelCurrentRun、bail触发时 abort,signal.reason携带取消原因(如超时错误); - 用法上只需把它传给
fetch、addEventListener、pipeTo、fs.readFile、spawn、setTimeout等接受{ signal }的 API,或在自己的轮询/等待 helper 中检查signal.aborted并在出口throwIfAborted(); - 源码层面,signal 由每个测试 context 独立的
AbortController支撑(context.ts),withTimeout/withCancel包装链把超时与取消统一转化为 Promise 的 reject(suite.ts)。
延伸阅读:Test Context 的 signal 条目、bail配置、testTimeout配置、vitest.cancelCurrentRun API。
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考