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 侧:
- getTestRunnerConstructor 通过
moduleRunner.import(config.runner)加载指定模块;若模块没有 default 导出(或 default 不是函数),会抛出Runner must export a default function, but got ...。 - resolveTestRunner 实例化构造函数,并自动注入两个私有成员:
moduleRunner:来自vite/module-runner的ModuleRunner实例(以不可枚举属性注入);- 若实例没有
config属性,会补上config;若没有importFile方法,会直接抛错Runner must implement "importFile" method.。
- 为了不让自定义 runner 重复实现 RPC 通信,
resolveTestRunner还会对onTaskUpdate、onTestAnnotate、onCollectStart、onCollected、onAfterRunFiles(负责收集覆盖率并上报)、onAfterRunTask(负责bail提前终止逻辑)做方法包裹,自动转发给主线程。
也就是说,一个最小的可用 runner 只需要实现importFile与config两个成员,其余钩子均为可选。
二、VitestRunner 接口全景:每个生命周期钩子
VitestRunner接口(源码定义见 types.ts)描述了一个运行器应当具备的全部能力。下面按"收集阶段 / 执行阶段 / 文件级 / 工具能力"四组逐一解读。
收集阶段
| 钩子 | 签名 | 说明 |
|---|---|---|
onBeforeCollect | (paths: string[]) => unknown | 在真正收集与运行测试之前最先被调用 |
onCollectStart | (file: File) => unknown | 文件任务已创建、但尚未开始收集时调用 |
onCollected | (files: File[]) => unknown | 收集完成、进入onBeforeRunFiles之前调用 |
onCollectStart在TestRunner中会把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已存在(含state与startTime) |
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 | 运行完毕,已有状态与结果 |
TestRunner的onAfterRunSuite是快照功能的核心落点(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) => unknown | 当test.extend使用{ injected: true }时,取值会走此函数 |
config | SerializedConfig | 必填。公开可用的序列化配置 |
pool | string | 当前 pool 名称,会影响服务端如何推断堆栈 |
viteEnvironment | string | 当前处理文件的 Vite 环境名 |
getImportDurations/getModuleFetchDuration | — | 模块导入耗时统计(服务于 import breakdown 报告) |
extendTaskContext在默认实现里为上下文注入了惰性求值的expect、bench与_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要点:
- 构造时拿到配置:Vitest 实例化 runner 类时会传入序列化配置,你必须把它暴露为
config属性(源码中resolveTestRunner会在缺失时兜底补上,但显式声明更清晰)。 - 继承
TestRunner:文档明确建议从vitest导入的TestRunner继承,以保留快照支持等依赖 runner 的能力;如需扩展基准测试(benchmark)能力,可使用NodeBenchmarkRunner。 - default 导出:加载器只认
default导出(index.ts)。
moduleRunner 与 importFile
Vitest 会向每个 runner 注入vite/module-runner的ModuleRunner实例(moduleRunner属性)。TestRunner与BenchmarkRunner的默认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上的公共字段还包括稳定的id、name、以>连接的fullName/fullTestName、mode(skip/only/todo/run/queued)、meta、each、concurrent、shuffle、retry/repeats、location、tags等。
TaskResult:任务的执行结果
每个 task 都可以有result字段。Suite 只有在其回调或beforeAll/afterAll抛错导致无法完成收集时才有result;Test 则在其回调执行后总是拥有result,其中state与errors依据结果而定。若错误发生在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,并支持meta、concurrent、location等字段)。
下面复刻文档中的"园艺"示例。先创建自定义任务收集器:
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,内置了concurrent、skip、only、todo、fails修饰符,并为每个收集器补齐each、for、skipIf、runIf、extend、override以及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关键设计要点
- 收集阶段不执行 handler:
createTaskCollector的回调在收集期运行,职责是"登记任务"(把handler、timeout、meta交给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 已自动代理:
onTaskUpdate、onCollected、onAfterRunFiles(覆盖率)、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),仅供参考