拓冰建站拓冰建站
首页 / 资讯中心 / 正文

深入掌握 Vitest 异步代码测试:从 async/await 到未处理拒绝的完整指南

深入掌握 Vitest 异步代码测试从 async/await 到未处理拒绝的完整指南【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitestJavaScript 代码几乎处处异步——请求数据、读写文件、等待定时器都是日常。而测试框架必须清楚被测代码何时才算真正执行完毕才能准确判定测试通过或失败。本文以 Vitest 官方指南 Testing Asynchronous Code 为骨架系统讲解异步测试的六大核心模式async/await、.resolves/.rejects、断言计数、回调包装、超时控制与未处理拒绝并结合当前仓库源码逐层剖析底层机制帮助你写出既可靠又高效的异步测试。为什么异步测试需要专门的模式JavaScript 的运行模型决定了测试天然会遇到异步问题fetch数据、fs读写、setTimeout定时器都会让代码在稍后才完成。测试框架如 Vitest必须知道被测代码何时结束才能继续执行下一个测试。如果框架在异步操作尚未完成时就判定测试结束就会出现两类典型问题假通过false positive断言还没执行测试就顺利结束了假失败false negative异步操作在测试结束后才失败导致结果不稳定、难以排查。Vitest 为此提供了一整套配套机制既覆盖最常见的async/await写法也提供断言计数、异步断言追踪、未处理拒绝检测等保护手段。下面逐一展开。Async/Await最直接的异步测试写法最直白的做法是让测试函数变成async。Vitest 会自动等待测试函数返回的 Promise 完成后再判定测试结果如果 Promise 被拒绝测试就以拒绝原因失败。import { expect, test } from vitest function fetchUser(id) { return Promise.resolve({ id, name: Alice }) } test(fetches user by id, async () { const user await fetchUser(1) expect(user.name).toBe(Alice) })这是绝大多数场景下你会用到的模式。它读起来和同步代码几乎一样错误也会顺着await自然向上传播——一旦fetchUser内部抛出异常或返回的 Promise 被拒绝测试立即失败并展示原始错误。从源码层面看Vitest 对等待测试函数完成的实现相当严谨。在 packages/vitest/src/runtime/runner/suite.ts 中withAwaitAsyncAssertions包装器会先await fn(...args)拿到测试函数结果再检查task.promises列表const fnResult await fn(...args) // some async expect will be added to this array, in case user forget to await them if (task.promises) { const result await Promise.allSettled(task.promises) // ...收集拒绝原因并抛出 }也就是说即便你在测试体内忘记await某个异步断言Vitest 也会在测试函数返回后兜底等待这些被记录下来的 Promise——这正是下一节要讲的.resolves/.rejects与未 await 检测能协同工作的基石。Resolves 与 Rejects直接对 Promise 断言有时候你并不想把 Promiseawait成一个变量再断言而是想直接对 Promise 本身做断言。Vitest 的.resolves与.rejects帮助函数正是为此设计它们会解包 Promise再把匹配器应用到解析值或拒绝值上。test(resolves to Alice, async () { await expect(fetchUser(1)).resolves.toMatchObject({ name: Alice }) }) test(rejects with an error, async () { await expect(fetchInvalidUser()).rejects.toThrow(User not found) })[!WARNING] 千万别忘了expect前面的await。Vitest 会检测到未被 await 的异步断言并在测试结束时让它失败。源码级原理基于 Proxy 的链式解包.resolves/.rejects的实现位于 packages/expect/src/jest-expect.ts核心是一个基于Proxy的惰性求值机制设置标记调用utils.flag(this, promise, resolves | rejects)并记录当前断言所属的测试对象vitest-testflag。类型校验若传入对象没有then方法会抛出TypeError例如You must provide a Promise to expect() when using .resolves, not ${typeof obj}.。值得注意的是.rejects还兼容 Jest 习惯——const wrapper typeof obj function ? obj() : obj即允许传入一个返回 Promise 的函数。Proxy 拦截链式调用后续的.toMatchObject(...)、.toThrow(...)等匹配器调用都会被 Proxy 拦截并转换为Promise.resolve(obj).then(成功分支, 失败分支).resolves在 Promise 解析后把解析值放回断言对象上执行匹配器若 Promise 反而被拒绝则抛出promise rejected ... instead of resolving的断言错误并把原始错误作为cause保留。.rejects恰好相反Promise 拒绝时把拒绝值交给匹配器如toThrow若 Promise 竟然解析了则抛出promise resolved ... instead of rejecting错误。记录异步断言最后通过recordAsyncExpect把这个 Promise 登记到当前测试的test.promises中。未 await 检测的兜底机制recordAsyncExpect定义在 packages/expect/src/utils.ts它同时负责未 await 检测返回的对象被设计成 thenable带then/catch/finally与Symbol.toStringTag一旦被await就会把resolved标记为true。同时它向test.onFinished注册回调——如果测试结束时resolved仍为false就抛出Promise returned by expect(actual).resolves.toMatchObject(expected) was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes:而测试结束时的兜底等待则由上面提到的withAwaitAsyncAssertionssuite.ts用Promise.allSettled(task.promises)统一收口所有被记录却未被 await 的异步断言都会在此被等待若其中有拒绝则测试失败。这一整套机制保证忘写await不再成为静默的假通过隐患。断言计数防止断言从未执行异步代码里有一个隐蔽的风险回调或.then()链里的断言可能从头到尾都没执行过但因为没有任何断言失败测试依然通过了。expect.hasAssertions()正是针对这个问题的护栏——它要求测试期间至少执行过一次断言。test(callback is invoked, async () { expect.hasAssertions() const data await fetchData() data.items.forEach((item) { expect(item.id).toBeDefined() }) // 如果 data.items 为空测试会失败而不是静默通过 })当你能精确预知断言数量时expect.assertions(n)更加精确test(both callbacks are called, async () { expect.assertions(2) await Promise.all([ fetchUser(1).then(user expect(user.name).toBe(Alice)), fetchUser(2).then(user expect(user.name).toBe(Bob)), ]) })大多数情况下直接使用async/await配合断言已经足够清晰并不需要断言计数。它的价值集中在断言位于回调、循环或条件分支内部时——用它可以保证这些断言确实被执行过。底层实现状态标记 结束时校验这两个方法的实现位于 packages/vitest/src/integrations/chai/index.ts本质是设置expect的全局状态expect.assertions(n)调用expect.setState({ expectedAssertionsNumber: n, expectedAssertionsNumberErrorGen })并预构造错误信息expected number of assertions to be ${expected}, but got ${expect.getState().assertionCalls}。expect.hasAssertions()调用expect.setState({ isExpectingAssertions: true, isExpectingAssertionsError })错误信息为expected any number of assertion, but got none。随后测试运行器会在测试结束时依据expect.getState()中的assertionCalls与实际期望做比对不满足即抛出预构造的错误。这也解释了为什么这两个方法必须在异步代码之前、同步地调用——它们设置的是断言计数基线。[!TIP] 如果希望项目里每个测试都至少执行一次断言可以在配置中开启expect.requireAssertions而不必在每个测试里手动添加expect.hasAssertions()。配置写法import { defineConfig } from vitest/config export default defineConfig({ test: { expect: { requireAssertions: true, }, }, })Callbacks把回调 API 包装成 Promise一些较老的 API 仍以回调而非 Promise 暴露异步能力。由于 Vitest 基于 Promise 工作最简单的做法就是把回调包装进一个Promisefunction fetchData(callback) { setTimeout(callback, 100, peanut butter) } test(the data is peanut butter, async () { const data await new Promise((resolve) { fetchData(resolve) }) expect(data).toBe(peanut butter) })这个模式适用于任何基于回调的 API把resolve作为成功回调传入测试就会一直等到回调被调用。若回调采用 Node.js 经典的(err, data)签名还可以配合rejectfunction readFile(path, callback) { // 模拟异步读文件 setTimeout(() callback(null, file content), 100) } test(reads file content via callback, async () { const content await new Promise((resolve, reject) { readFile(a.txt, (err, data) (err ? reject(err) : resolve(data))) }) expect(content).toBe(file content) })[!TIP] 大多数现代 Node.js API如fs/promises、fetch已原生支持 Promise可以直接使用async/await。回调包装模式主要面向尚未采用 Promise 的旧库。Timeouts超时控制防止测试卡死默认情况下每个测试的超时时间是5 秒。如果测试运行超过这个时间比如 Promise 永不 resolve或网络请求挂起测试会以超时错误失败——这避免了测试套件无限期卡住。可以为单个测试设置自定义超时只需作为test的第三个参数传入test(long-running operation, async () { await someSlowOperation() }, 10_000) // 10 秒如果大量测试都需要更长的超时时间可以通过配置项testTimeout统一修改默认值import { defineConfig } from vitest/config export default defineConfig({ test: { testTimeout: 10_000, }, })除了全局默认值外Vitest 还支持更细粒度的控制维度可参考 testtimeout 配置文档 与 hooktimeout 配置文档钩子超时hookTimeout单独控制beforeEach/afterEach等钩子的超时避免与用例本身超时互相影响套件级覆盖在describe中也可按套件为内部所有用例统一设置超时优先级单测参数第三参数 套件级配置 全局testTimeout默认值。在 CI 环境或依赖网络的外部服务测试中合理配置超时尤为关键——它同时保护了外部服务变慢导致测试套件卡死与慢操作被误杀两个方向。未处理拒绝默认报错主动修复默认情况下Vitest 会把未处理的 Promise 拒绝unhandled rejection报告为测试运行中的错误。如果你的代码里有 Promise 被拒绝却无人捕获即使所有断言都通过了测试运行依然会失败。这是刻意为之未处理的拒绝通常意味着真实缺陷——比如忘了await或某个发出去就不管fire-and-forget的 Promise 静默失败。test(this causes an unhandled rejection error, () { // 这个 Promise 被拒绝但从未被 await 或捕获 Promise.reject(new Error(oops)) })修复方式很简单await所有 Promise或显式捕获预期内的拒绝。test(handle the rejection, async () { // 要么 await 这个 Promise await expect(Promise.reject(new Error(oops))).rejects.toThrow(oops) // 要么显式捕获如果不需要对它断言 Promise.reject(new Error(expected)).catch(() {}) })过滤与关闭onUnhandledError 与 dangerouslyIgnoreUnhandledErrors如果你的代码有意产生未处理拒绝可以通过onUnhandledError过滤特定错误或通过dangerouslyIgnoreUnhandledErrors完全关闭检查。从源码看默认配置定义在 packages/vitest/src/defaults.ts 与 packages/vitest/src/defaults.tsdangerouslyIgnoreUnhandledErrors: boolean // 类型声明 dangerouslyIgnoreUnhandledErrors: false, // 默认值不忽略它贯穿了从 CLI 参数解析、配置合并到运行时错误捕获的整条链路可检索packages/vitest/src/node/cli/cac.ts、packages/vitest/src/node/core.ts、packages/vitest/src/runtime/moduleRunner/errorCatcher.ts等文件中的同名标识。需要强调的是dangerouslyIgnoreUnhandledErrors是危险选项开启后会把未处理拒绝从必现错误降级为静默忽略可能掩盖真实 bug仅在你有充分理由如测试第三方库的固有行为时才应使用。onUnhandledError则提供更精细的控制它是一个回调钩子可以检查错误的类型与来源仅对符合条件的错误放行。例如import { defineConfig } from vitest/config export default defineConfig({ test: { onUnhandledError(error) { // 仅忽略 AbortError 类型的未处理拒绝 return error instanceof Error error.name AbortError }, }, })总结一套完整的异步测试决策清单场景推荐方案关键注意点大多数异步代码async/await 直接断言错误会沿await自然传播直接对 Promise 断言.resolves/.rejects必须await否则测试结束时报错断言位于回调/循环/分支内expect.assertions(n)/expect.hasAssertions()在异步代码前同步调用旧式回调 API包装成Promise传resolve作为成功回调长耗时操作test(name, fn, timeout)或全局testTimeout单测参数优先级最高未处理拒绝修复await/捕获必要时onUnhandledError过滤默认即报错dangerouslyIgnoreUnhandledErrors慎用Vitest 之所以能把这些模式融合得如此顺畅根因在于其运行时的异步断言登记设计从 recordAsyncExpect 登记每个异步断言到 withAwaitAsyncAssertions 在测试函数返回后统一allSettled兜底再到test.promises的自动清理机制构成了一个防忘 await、防假通过的完整闭环。理解这一闭环后你不仅能熟练写出异步测试还能在遇到奇怪的测试通过但报告错误时快速定位根因。如果你想继续深入推荐依次阅读Matchers 指南掌握更多可直接配合异步断言的匹配器Setup and Teardown学习钩子函数与异步清理的配合Testing Asynchronous Code 原文档本指南的官方底稿expect API 参考.resolves、.rejects、assertions、hasAssertions的完整签名与示例test API 参考timeout等参数说明。【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门