news 2026/9/13 15:17:24

Vitest Runner API 完全指南:自定义测试运行器与任务收集器实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vitest Runner API 完全指南:自定义测试运行器与任务收集器实战

Vitest Runner API 完全指南:自定义测试运行器与任务收集器实战

【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest

本文面向需要深度定制 Vitest 行为的开发者(尤其是测试库/框架的作者)。你将掌握如何通过配置runner选项注入自定义测试运行器,理解VitestRunner生命周期钩子的完整调用时序与Task(任务)数据结构,并能用createTaskCollector创建带todo/each/only等链式能力的自定义测试方法。全文以 docs/api/advanced/runner.md 为骨架,并结合本仓库源码(types.ts、test.ts、index.ts、suite.ts)逐层剖析实现原理。

::: warning 这是一套高级 API。如果你只是想运行测试,参考 测试指南 即可,绝大多数用户并不需要它。本 API 主要面向测试库作者,用于实现自定义测试语义(如 BDD 之外的 DSL、自定义断言入口、特殊执行策略等)。 :::

一、Runner 是什么:入口与配置方式

Vitest 把"收集测试 → 执行测试 → 汇总结果"这套流程抽象为可替换的Runner(运行器)。你可以通过配置文件中的runner选项,指向一个自定义运行器文件:

// vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { runner: './src/my-runner.ts', // 指向导出 default 构造函数的模块 }, })

该配置项的完整定义见 docs/config/runner.md:类型为VitestRunnerConstructor,即new (config: SerializedConfig) => VitestRunner的构造函数类型(见 types.ts)。

底层加载链路

从源码看,自定义 runner 的加载与校验发生在 worker 侧:

  1. getTestRunnerConstructor 通过moduleRunner.import(config.runner)加载指定模块;若模块没有 default 导出(或 default 不是函数),会抛出Runner must export a default function, but got ...
  2. resolveTestRunner 实例化构造函数,并自动注入两个私有成员:
    • moduleRunner:来自vite/module-runnerModuleRunner实例(以不可枚举属性注入);
    • 若实例没有config属性,会补上config;若没有importFile方法,会直接抛错Runner must implement "importFile" method.
  3. 为了不让自定义 runner 重复实现 RPC 通信,resolveTestRunner还会对onTaskUpdateonTestAnnotateonCollectStartonCollectedonAfterRunFiles(负责收集覆盖率并上报)、onAfterRunTask(负责bail提前终止逻辑)做方法包裹,自动转发给主线程。

也就是说,一个最小的可用 runner 只需要实现importFileconfig两个成员,其余钩子均为可选。

二、VitestRunner 接口全景:每个生命周期钩子

VitestRunner接口(源码定义见 types.ts)描述了一个运行器应当具备的全部能力。下面按"收集阶段 / 执行阶段 / 文件级 / 工具能力"四组逐一解读。

收集阶段

钩子签名说明
onBeforeCollect(paths: string[]) => unknown在真正收集与运行测试之前最先被调用
onCollectStart(file: File) => unknown文件任务已创建、但尚未开始收集时调用
onCollected(files: File[]) => unknown收集完成、进入onBeforeRunFiles之前调用

onCollectStartTestRunner中会把workerState.current指向当前文件(test.ts),resolveTestRunner还会在其外层包裹rpc().onQueued(file)上报排队事件;onCollected则被包裹了prepareDuration/environmentLoad时长上报与retry.condition函数清理逻辑(函数无法被结构化克隆,需在 RPC 前剥离,见 index.ts)。

执行阶段:task 级钩子

钩子签名调用时机与语义
onBeforeRunTask(test: Test) => unknown运行单个测试之前,此时还没有result
onBeforeTryTask(test: Test, options: { retry: number; repeats: number }) => unknown真正执行测试函数之前,此时result已存在(含statestartTime
onAfterTryTask(test: Test, options: { retry: number; repeats: number }) => unknown测试函数执行完毕后立即调用,尚无新状态;若测试函数抛出异常则不会被调用
onAfterRetryTask(test: Test, options: { retry: number; repeats: number }) => unknown重试(retry)流程落定之后调用,此时测试拥有新状态,且所有after钩子均已执行
onAfterRunTask(test: Test) => unknown结果与状态都已写入之后调用
onTaskFinished(test: Test) => unknown任务运行结束、但清理钩子(cleanup hooks)尚未执行时调用

其中TestTryOptions{ retry: number; repeats: number },源码见 types.ts。

以默认TestRunner的实现为例,你可以直观看到这些钩子的"真实工作":

  • onBeforeRunTask:若收到取消信号(cancelRun)则把test.mode置为skip
  • onBeforeTryTask:执行clearModuleMocks(按clearMocks/mockReset/restoreMocks/unstubEnvs/unstubGlobals配置清理vi状态)、重置快照客户端,并向全局 expect 注入断言计数状态;
  • onAfterTryTask:校验expect.assertions(n)/expect.hasAssertions()/requireAssertions的断言数量是否达标;
  • onAfterRunTask:在开启logHeapUsage时记录堆内存占用,并恢复workerState.current

执行阶段:suite 级钩子与运行器替换

钩子签名说明
onBeforeRunSuite(suite: Suite) => unknown运行单个套件之前,此时没有result
onAfterRunSuite(suite: Suite) => unknown运行完毕,已有状态与结果

TestRunneronAfterRunSuite是快照功能的核心落点(test.ts):它把跳过的测试标记为"非废弃快照"、调用snapshotClient.finish写快照、在updateSnapshot === 'none'时把"存在废弃快照"升级为失败错误,并通过rpc().snapshotSaved(result)上报。这也是文档强调"快照支持依赖 runner、建议继承TestRunner"的原因。

更进一步,接口允许你整体替换默认执行逻辑

  • runSuite?: (suite: Suite) => Promise<void>:若定义,将取代 Vitest 默认的套件分区与处理流程;before/after钩子不会被忽略。
  • runTask?: (test: TaskPopulated) => Promise<void>:若定义,将取代默认的测试执行逻辑,适合为自定义测试函数提供执行入口;同样不会忽略before/after钩子。

文件级与工具能力

成员签名说明
onBeforeRunFiles(files: File[]) => unknown运行所有已收集文件之前
onAfterRunFiles(files: File[]) => unknown运行完所有文件之后
onTaskUpdate(task: TaskResultPack[], events: TaskEventPack[]) => Promise<void>任务状态更新时回调;与 reporter 的onTaskUpdate等价,但运行在与测试相同的线程中
extendTaskContext(context: TestContext) => TestContext测试上下文创建时回调,可注入自定义属性;若只想扩展上下文,文档建议优先用setupFiles里的beforeAll
importFile(filepath: string, source: VitestRunnerImportSource) => unknown必填。文件被导入时调用,发生在两种场景:收集测试、导入 setup 文件;source取值为'collect' | 'setup'
injectValue(key: string) => unknowntest.extend使用{ injected: true }时,取值会走此函数
configSerializedConfig必填。公开可用的序列化配置
poolstring当前 pool 名称,会影响服务端如何推断堆栈
viteEnvironmentstring当前处理文件的 Vite 环境名
getImportDurations/getModuleFetchDuration模块导入耗时统计(服务于 import breakdown 报告)

extendTaskContext在默认实现里为上下文注入了惰性求值的expectbench_local属性(test.ts)。

三、编写你的第一个自定义 Runner

结合上面的接口,一个最小可用的自定义 runner 长这样(源自 docs/api/advanced/runner.md):

import type { RunnerTestFile, SerializedConfig, TestRunner, VitestTestRunner } from 'vitest' class CustomRunner extends TestRunner implements VitestTestRunner { public config: SerializedConfig constructor(config: SerializedConfig) { this.config = config } onAfterRunFiles(files: RunnerTestFile[]) { console.log('finished running', files) } } export default CustomRunner

要点:

  1. 构造时拿到配置:Vitest 实例化 runner 类时会传入序列化配置,你必须把它暴露为config属性(源码中resolveTestRunner会在缺失时兜底补上,但显式声明更清晰)。
  2. 继承TestRunner:文档明确建议从vitest导入的TestRunner继承,以保留快照支持等依赖 runner 的能力;如需扩展基准测试(benchmark)能力,可使用NodeBenchmarkRunner
  3. default 导出:加载器只认default导出(index.ts)。

moduleRunner 与 importFile

Vitest 会向每个 runner 注入vite/module-runnerModuleRunner实例(moduleRunner属性)。TestRunnerBenchmarkRunner的默认importFile行为就是委托给它:

export default class Runner { async importFile(filepath: string) { await this.moduleRunner.import(filepath) } }

ModuleRunner.import会在运行时解析导入并转换文件内容,让 Node 能直接理解 Vite 生态的模块。默认TestRunner的实现还做了两件事(test.ts):

  • 未启用experimental.viteModuleRunner时,为文件路径追加?vitest=${Date.now()}查询串以绕过模块缓存;
  • 在 OpenTelemetry 追踪跨度vitest.module.import_collect/vitest.module.import_setup下执行导入。

若你既没有自定义 runner,也没有定义runTest/runTask方法,Vitest 会尝试自动取回任务;如果任务没有通过setFn注册函数,运行会直接失败。因此在自定义执行流程时,务必确保任务函数已被正确设置(TestRunner静态方法中提供了setTestFn/getTestFn等工具,见 test.ts)。

取消机制

cancel(reason: CancelReason)会在需要取消后续测试运行时被调用。CancelReason的类型为'keyboard-input' | 'test-failure' | (string & ...)(types.ts),即键盘中断或测试失败触发。runner 应当监听该方法,并在onBeforeRunSuite/onBeforeRunTask中把后续任务标记为skip——这正是默认实现的策略(cancelRun标志位,见 test.ts 与 onBeforeRunTask)。bail配置也是通过rpc().onCancel('test-failure')加上testRunner.cancel('test-failure')触发的(index.ts)。

四、理解 Tasks:Runner 视角的任务树

Suites 与 tests 在内部统称为tasks(任务)。这一层 API 目前标记为experimental,应主要在测试运行时使用;如果在主线程(例如 reporter 内)工作,应优先使用 Reported Tasks API——团队正在讨论未来是否用 Reported Tasks 取代 Runner Tasks。

File:文件的根任务

收集任何测试之前,runner 会先创建一个File任务——它是Suite的超集,额外携带:

interface File extends Suite { /** 文件所属的 pool 名称,默认 'forks' */ pool?: string /** UNIX 格式的文件路径 */ filepath: string /** 文件所属测试项目名 */ projectName: string | undefined /** 收集该文件所有测试的耗时(含导入全部依赖的时间) */ collectDuration?: number /** 导入 setup 文件的耗时 */ setupDuration?: number }

Suite 与 Test

每个 suite 拥有tasks: Task[]属性,在收集阶段被填充,适合自上而下遍历任务树:

interface Suite extends TaskBase { type: 'suite' /** 文件的根任务 */ file: File /** 属于该 suite 的任务数组 */ tasks: Task[] }

每个 task 又通过suite属性反向引用其所在套件,适合自下而上回溯。注意三条边界规则(源码中的TaskBase/Test定义见 types.ts):

  • 在顶层(文件根部)声明的test/describe没有suite属性(它等于file!);
  • File本身永远没有suite属性;
  • 每个 task 的file属性总是指向文件根任务。

Test还带有测试上下文与执行辅助字段:

interface Test<ExtraContext = object> extends TaskBase { type: 'test' /** 传给测试函数的上下文 */ context: TestContext & ExtraContext /** 文件的根任务 */ file: File /** 是否通过 context.skip() 跳过 */ pending?: boolean /** 期望失败:失败时会被标记为通过 */ fails?: boolean /** 存储异步 expect 的 promise,在测试结束前等待它们 */ promises?: Promise<any>[] }

TaskBase上的公共字段还包括稳定的idname、以>连接的fullName/fullTestNamemodeskip/only/todo/run/queued)、metaeachconcurrentshuffleretry/repeatslocationtags等。

TaskResult:任务的执行结果

每个 task 都可以有result字段。Suite 只有在其回调或beforeAll/afterAll抛错导致无法完成收集时才有result;Test 则在其回调执行后总是拥有result,其中stateerrors依据结果而定。若错误发生在beforeEach/afterEach中,错误会出现在task.result.errors里。

export interface TaskResult { /** 任务状态。收集期间继承 task.mode;结束后变为 pass 或 fail */ state: TaskState /** 执行期间的错误;expect.soft() 多次失败时可能有多条 */ errors?: TestError[] /** 任务运行耗时(毫秒) */ duration?: number /** 任务开始运行的时间戳(毫秒) */ startTime?: number /** 任务结束后的堆大小(字节)。仅当启用 logHeapUsage 且存在 process.memoryUsage 时可用 */ heap?: number /** 与任务相关的钩子状态,便于报告使用 */ hooks?: Partial<Record<'afterAll' | 'beforeAll' | 'beforeEach' | 'afterEach', TaskState>> /** 重试次数。仅当任务失败且设置了 retry 时才会重试 */ retryCount?: number /** 重复次数。仅当设置了 repeats 时才会重复;该数字包含 retryCount */ repeatCount?: number }

五、自定义 Task 函数:createTaskCollector 实战

Vitest 暴露了createTaskCollector工具,用来创建你自己的test方法。它与内置test行为一致,但在收集阶段会调用你传入的自定义逻辑。

原理:getCurrentSuite().task()

一个 task 本质上是 suite 中的对象,通过suite.task()方法自动加入当前套件(源码见 suite.ts,其中task方法会合并父套件选项与 tags、计算timeout、创建TestContext、注册 handler,并支持metaconcurrentlocation等字段)。

下面复刻文档中的"园艺"示例。先创建自定义任务收集器:

export { afterAll, beforeAll, describe, TestRunner } from 'vitest' // 该函数在收集阶段被调用: // 不要在这里直接调用函数 handler,而是通过 // "getCurrentSuite().task()" 方法把它加入套件任务 // 注意:createTaskCollector 自动提供 "todo"/"each"/... 等链式支持 export const myCustomTask = TestRunner.createTaskCollector( function (name, fn, timeout) { TestRunner.getCurrentSuite().task(name, { ...this, // 确保 "todo"/"skip"/... 等修饰符被正确跟踪 meta: { customPropertyToDifferentiateTask: true }, handler: fn, timeout, }) } )

从源码看,createTaskCollector(suite.ts)返回的是一个经由createChainable包装的链式 API,内置了concurrentskiponlytodofails修饰符,并为每个收集器补齐eachforskipIfrunIfextendoverride以及describe/suite/beforeEach/afterEach/beforeAll/afterAll/aroundEach/aroundAll等属性——所以你自定义的 task 天然拥有与test等价的完整语法糖。

然后在测试文件中使用:

import { afterAll, beforeAll, describe, myCustomTask } from './custom.js' import { gardener } from './gardener.js' describe('take care of the garden', () => { beforeAll(() => { gardener.putWorkingClothes() }) myCustomTask('weed the grass', () => { gardener.weedTheGrass() }) myCustomTask.todo('mow the lawn', () => { gardener.mowerTheLawn() }) myCustomTask('water flowers', () => { gardener.waterFlowers() }) afterAll(() => { gardener.goHome() }) })

运行:

vitest ./garden/tasks.test.js

关键设计要点

  • 收集阶段不执行 handlercreateTaskCollector的回调在收集期运行,职责是"登记任务"(把handlertimeoutmeta交给getCurrentSuite().task()),真正的测试函数由运行阶段取出执行。
  • ...this透传修饰符skip/only/todo等链式调用产生的标志位于this上,展开后传入task(),保证myCustomTask.skip(...)myCustomTask.only(...)行为正确。
  • meta 自定义元数据:通过meta字段可以给任务打上自定义标记,例如示例中的customPropertyToDifferentiateTask,JSON reporter 会保存这些数据,后续可据此做差异化处理。

仓库中的单测 test/unit/test/task-collector.test.ts 对这套机制做了直接验证:例如验证collector.each([1])('a', cb, options.timeout)collector.each([1])('a', options, cb)两种签名都能工作、suite.task能继承 suite 的meta/concurrent/repeats/retry/timeout选项、以及"空 suite / 空 test 自动降级为 todo"的行为(empty tests and suites are todos)。

六、边界与注意事项

  • 快照依赖 runner:快照支持等特性与 runner 深度耦合(默认实现在 test.ts 的onAfterRunSuite中完成快照落盘与废弃检测)。如果不希望失去快照能力,务必继承TestRunner
  • 必须实现importFile:否则resolveTestRunner会直接抛错。
  • 必须 default 导出构造函数:加载器通过moduleRunner.import(config.runner)mod.default
  • RPC 已自动代理onTaskUpdateonCollectedonAfterRunFiles(覆盖率)、onAfterRunTask(bail)等已被resolveTestRunner包裹,自定义 runner 无需(也不应)自行调用rpc()
  • Runner Tasks 是实验性 API:文档明确提示它应主要用于测试运行时;主线程(reporter)场景请使用 Reported Tasks API,二者未来可能合并。
  • runTest/setFn的对应关系:如果自定义了执行流程但没有通过setFn给任务注册函数,Vitest 自动取回任务时会失败。请使用 TestRunner 暴露的静态工具(setTestFn/getTestFn/setSuiteHooks/getSuiteHooks/matchesTags/createFileTask等)来管理任务函数与钩子。

相关资源

  • 本文骨架来源:docs/api/advanced/runner.md
  • 配置项定义:docs/config/runner.md
  • 接口与类型定义:packages/vitest/src/runtime/runner/types.ts(VitestRunner见 L1623-L1773)
  • 默认运行器实现:packages/vitest/src/runtime/runners/test.ts(TestRunner类见 L36-L304)
  • 加载与代理逻辑:packages/vitest/src/runtime/runners/index.ts
  • 任务收集器实现:packages/vitest/src/runtime/runner/suite.ts(createTaskCollector见 L744-L976)
  • 相关测试:test/unit/test/task-collector.test.ts

【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

低配电脑Win10越用越卡?手把手教你从启动项到虚拟内存全面优化

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

作者头像 李华
网站建设 2026/9/13 15:16:59

AI编程工具实战图谱:上下文理解、工程约束与私有化确定性

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

作者头像 李华
网站建设 2026/9/13 15:16:48

一键下载、安装、激活 Office:LKY_OfficeTools 实战指南

一键下载、安装、激活 Office&#xff1a;LKY_OfficeTools 实战指南 【免费下载链接】LKY_OfficeTools 一键自动化 下载、安装、激活 Office 的利器。 项目地址: https://gitcode.com/GitHub_Trending/lk/LKY_OfficeTools LKY_OfficeTools 是一款开源的 Office 自动部署…

作者头像 李华
网站建设 2026/9/13 15:15:44

ASM330陀螺仪例程深度解析:寄存器配置、标定滤波与工程验证

简介&#xff1a;针对ASM330陀螺仪设计的一套嵌入式开发例程&#xff0c;面向运动控制、导航与姿态估计场景&#xff0c;帮助开发者解决传感器接口配置、数据读取、滤波处理及校准等基础问题。资源共九个文件&#xff0c;由八个C语言源文件和一个头文件构成核心驱动&#xff0c…

作者头像 李华
网站建设 2026/9/13 15:12:29

C++ vector插入性能真相:emplace_back与push_back的内存构造差异

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

作者头像 李华