news 2026/9/14 15:11:58

Vitest 可取消测试资源:用 test context 的 signal 在超时、bail 与 Ctrl+C 时释放资源

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vitest 可取消测试资源:用 test context 的 signal 在超时、bail 与 Ctrl+C 时释放资源

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 状态:

  1. 测试超时:测试运行时间超过单测timeout参数或全局testTimeout配置;
  2. 手动取消:用户在终端按 Ctrl+C;
  3. 程序化取消:通过vitest.cancelCurrentRun显式取消整个测试运行;
  4. bail 联动:在并行运行时另一个测试失败,且配置了bail(默认1),后续未开始的测试会被取消。

不处理这些场景的常见后果:超时后 worker 仍在等一个永远不会 resolve 的fetch,测试文件挂死;轮询循环持续打满本地服务的端口;子进程泄漏。signal的价值就在于把"Vitest 决定不再等"这一决策同步传递给资源本身。

核心模式:把 signal 传给 fetch

recipe 给出的最小可用示例,将测试的signal直接传给fetchAbortSignal选项:

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.readFilefs/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(超时错误)。随后两件事同时发生:

  1. 所有携带该signal的 API(fetchspawn、定时器)各自以取消结束,资源被释放;
  2. 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 超时的测试中监听signalabort事件并写task.meta,断言 stderr 含Test timed out in 10ms.meta{ aborted: true };两个用例分别覆盖"未使用 fixture"和"通过test.extend强制初始化 fixture"两条路径;
  • timeout aborts all signals in concurrent teststest.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、cancelCurrentRunbail触发时 abort,signal.reason携带取消原因(如超时错误);
  • 用法上只需把它传给fetchaddEventListenerpipeTofs.readFilespawnsetTimeout等接受{ 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 15:10:52

Git文件状态标志详解:U、M、D、A在编辑器中究竟代表什么?

如果你用的是VS Code这类带Git集成的编辑器&#xff0c;应该早就注意到一个现象&#xff1a;文件列表里某些文件名的末尾会挂着小字母——有时是U&#xff0c;有时是M&#xff0c;偶尔是D&#xff0c;颜色还各不相同。我当年第一次看到时&#xff0c;第一反应是“插件是不是装坏…

作者头像 李华
网站建设 2026/9/14 15:10:27

C盘爆满不用慌:从根源揪出空间吸血鬼,一套方法彻底搞定

这几年帮人修电脑、做系统维护&#xff0c;几乎每周都能遇到“C盘又红了”的求助。磁盘占用一高&#xff0c;电脑就开始卡顿&#xff0c;软件装不上&#xff0c;系统更新卡死&#xff0c;连聊天软件都打不开——所有问题最后都指向同一个原因&#xff1a;C盘满了。我自己的主力…

作者头像 李华
网站建设 2026/9/14 15:10:20

论文AI率过高?嘎嘎降AI与比话降AI工具实测对比

一张截图把我问住了。表弟发来论文检测报告&#xff0c;红色的"AI疑似度 43.8%"十分显眼&#xff0c;导师的原话是&#xff1a;自己写的就当面解释清楚&#xff0c;不是自己写的就逐句改。可问题在于&#xff0c;他的初稿确实用AI做过资料整理和语言组织&#xff0c;…

作者头像 李华
网站建设 2026/9/14 15:09:34

Anaconda3 conda命令详解:环境管理与包管理实战指南

1. Anaconda3 核心命令&#xff1a;环境管理才是重头戏1.1 创建与删除环境&#xff1a;别再把 base 环境弄乱了很多人把 Anaconda 当成一个普通 Python 安装包&#xff0c;装完就一直在 base 环境里用pip install装库。这其实是效率最低、风险最高的用法。我刚开始处理数据那会…

作者头像 李华
网站建设 2026/9/14 15:09:18

BLE指令驱动语音播报:超低功耗语音触发技术实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华