Vitest runner 配置详解:定义与实现自定义测试运行器(Custom Test Runner)
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
本文围绕 Vitest 的runner配置项展开,讲解如何通过一个字符串路径把 Vitest 的测试执行流程替换为自定义 Runner:包括配置项的类型与解析来源、VitestRunner完整生命周期接口、继承内置TestRunner的最小可用示例,以及任务模型(Task)与createTaskCollector自定义测试函数 API。读完后可掌握编写/替换测试运行器的完整实操路径,理解 Vitest 在 Worker 内是如何加载并“打补丁”接管自定义 Runner 的。
1. runner 配置项概览
runner是 Vitest 中的一个进阶(advanced)配置项,用于指定自定义测试运行器(test runner)的模块路径。官方文档明确建议:该功能主要面向库作者,配合自定义 runner 库使用,普通用户跑测试并不需要用它(见 docs/config/runner.md 与 Runner API)。
| 项目 | 说明 |
|---|---|
| 配置名 | test.runner |
| 类型 | 字符串(模块路径)。文档中标注的运行时构造器类型为VitestRunnerConstructor |
| 默认值 | 未设置时使用内置TestRunner(源码中config.runner为假值时直接返回TestRunner,见 runners/index.ts#L11-L27) |
| 适用场景 | 库作者提供自定义 runner;替换测试执行/收集逻辑;实现自定义任务类型 |
在配置文件中的典型写法如下(runner的取值为一个可被 Worker 内模块运行器导入的模块路径,该模块必须提供 default export):
// vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { // 指向一个默认导出 Runner 类构造器的文件 runner: './vitest.runner.ts', }, })从源码的类型定义可以看到该配置项的声明位置与含义(config.ts#L776-L779):
/** * Path to a custom test runner. */ runner?: string另外,runner属于可以按 project 维度解析的选项之一(出现在 per-project resolved config 的字段列表中,见 config.ts#L1208-L1237),也就是说在多项目(projects/workspace)场景下,不同项目可以各自指定不同的 runner。它最终会进入序列化后的运行时配置SerializedConfig.runner,随配置下发到 Worker(见 runtime/config.ts#L49)。
2. VitestRunnerConstructor:构造器类型契约
自定义 Runner 模块的导出类型在运行时类型文件中有明确定义(runner/types.ts#L1607-L1615):
export type VitestRunnerImportSource = 'collect' | 'setup' export interface VitestRunnerConstructor { new (config: SerializedConfig): VitestRunner } export type CancelReason = | 'keyboard-input' | 'test-failure'要点:
- 构造函数只接收一个参数:Vitest 序列化后的配置对象
SerializedConfig。Runner 类应当把它暴露为自己的config属性; - 模块必须有 default export。Worker 侧的加载逻辑会检查
mod.default,缺失时直接抛出Runner must export a default function, but got ...错误; VitestRunnerImportSource表示importFile的触发来源:'collect'(收集测试时导入文件)或'setup'(导入 setup 文件)。
3. 源码剖析:Worker 如何加载并接管自定义 Runner
Runner 的解析与实例化发生在resolveTestRunner(runners/index.ts#L29-L153),这条调用链解释了runner配置在运行时的完整行为:
- 解析构造器(
getTestRunnerConstructor,runners/index.ts#L11-L27):
async function getTestRunnerConstructor( config: SerializedConfig, moduleRunner: TestModuleRunner, ): Promise<VitestRunnerConstructor> { if (!config.runner) { return TestRunner as any as VitestRunnerConstructor } const mod = await moduleRunner.import(config.runner) if (!mod.default && typeof mod.default !== 'function') { throw new Error( `Runner must export a default function, but got ${typeof mod.default} imported from ${ config.runner }`, ) } return mod.default as VitestRunnerConstructor }注意这里通过moduleRunner.import(config.runner)导入 runner 文件——意味着 runner 文件本身也走 Vite 的转换/解析管线,可以写成 TypeScript、使用 ESM 语法,无需预先编译为 CJS。
- 实例化并注入
moduleRunner:
const TestRunner = await getTestRunnerConstructor(config, moduleRunner) const testRunner = new TestRunner(config) // inject private executor to every runner Object.defineProperty(testRunner, 'moduleRunner', { value: moduleRunner, enumerable: false, configurable: false, })Vitest 会把vite/module-runner的ModuleRunner实例以不可枚举的moduleRunner属性注入到每一个runner 实例上(包括内置 Runner)。你可以在importFile方法中调用this.moduleRunner.import(filepath)来在 Vite 友好的环境中导入测试文件(解析导入、运行时转换内容,让 Node 能理解)。这是内置TestRunner与BenchmarkRunner的默认行为。
- 校验与兜底:
if (!testRunner.config) { testRunner.config = config } if (!testRunner.importFile) { throw new Error('Runner must implement "importFile" method.') }即使你忘记在构造器中保存config,Vitest 也会帮你补上;但importFile是必须实现的方法(VitestRunner接口中唯一非可选成员)。
自动打补丁,让自定义 Runner 无需手写 RPC(runners/index.ts#L62-L150)。Vitest 会在原方法外层包裹一层,先调用
rpc()再委托给你的实现:onTaskUpdate:转发任务更新(result/meta)到主线程,再调用你的onTaskUpdate;onTestAnnotate/onTestArtifactRecord:注解与 artifact 记录自动上报;onCollectStart:自动rpc().onQueued(file)上报文件入队;onCollected:写入prepareDuration/environmentLoad等耗时字段,并在 RPC 前清洗掉不可结构克隆的函数型retry.condition;onAfterRunFiles:自动在 Worker 内采集 coverage 并上报;onAfterRunTask:在bail配置达到阈值时自动触发rpc().onCancel('test-failure')并调用testRunner.cancel('test-failure')。
从这段源码结构看,自定义 Runner 只需要关心测试执行语义本身:状态上报、coverage 采集、bail 取消等基础设施已由框架代劳。同时CancelReason('keyboard-input' | 'test-failure')也正是通过onCancel/cancel通道传递的原因类型。
4. VitestRunner 接口:完整生命周期方法
自定义 Runner 需要实现的接口为VitestRunner(文档:docs/api/advanced/runner.md)。以下是文档定义的完整方法清单(按执行时序分组),全部方法除importFile与config外均为可选:
4.1 收集(Collection)阶段
| 方法 | 时机 | 说明 |
|---|---|---|
onBeforeCollect?(paths: string[]) | 收集与运行测试之前 | 最先被调用的钩子 |
onCollected?(files: File[]) | 收集完成后、onBeforeRunFiles之前 | 可拿到所有File任务 |
importFile(filepath, source) | 文件被导入时(必选) | source为'collect'或'setup',两种情况都会触发 |
4.2 文件级执行阶段
| 方法 | 时机 | 说明 |
|---|---|---|
onBeforeRunFiles?(files: File[]) | 运行收集到的所有文件之前 | 文件级前置钩子 |
onAfterRunFiles?(files: File[]) | 所有文件运行完成之后 | 文件级后置钩子 |
4.3 套件(Suite)与测试(Test)执行阶段
| 方法 | 时机 | 说明 |
|---|---|---|
onBeforeRunSuite?(suite: Suite) | 运行单个套件前 | 尚无result |
onAfterRunSuite?(suite: Suite) | 运行单个套件后 | 已有状态与结果 |
onBeforeRunTask?(test: Test) | 运行单个测试前 | 尚无result |
onBeforeTryTask?(test, { retry, repeats }) | 实际执行测试函数前 | 已有含state和startTime的result |
onAfterTryTask?(test, { retry, repeats }) | 测试函数刚执行完 | 尚无新状态;测试函数抛错时不会调用 |
onAfterRunTask?(test) | 结果与状态写入之后 | 测试级收尾 |
onAfterRetryTask?(test, { retry, repeats }) | 重试决议完成后 | 与onAfterTryTask不同,此时测试已有新状态,且所有after钩子均已执行 |
4.4 执行逻辑替换与辅助方法
| 方法 | 用途 |
|---|---|
runSuite?(suite: Suite): Promise<void> | 定义后将替代Vitest 默认的套件拆分与执行逻辑;before/after钩子不会被忽略 |
runTask?(test: TaskPopulated): Promise<void> | 定义后将替代默认测试执行逻辑,适合你有自定义测试函数(custom test function)的场景 |
onTaskUpdate?(tasks: TaskResultPack[]) | 任务更新时调用。等价于 reporter 的onTaskUpdate,区别是它在与测试相同的线程中运行 |
onCancel?(reason: CancelReason) | 当 runner 需要取消后续测试时调用。Runner 应在收到后监听此事件,并在onBeforeRunSuite/onBeforeRunTask中标记套件与测试为跳过 |
extendTaskContext?(context: TestContext): TestContext | 为测试上下文添加自定义属性;若只是想定义自定义上下文,文档建议改用setupFiles里的beforeAll |
injectValue?(key: string) | 使用test.extend且 fixture 标记{ injected: true }时,Runner 尝试取值的回调 |
config: SerializedConfig | 公开的(序列化)Vitest 配置,必须作为属性暴露 |
pool?: string | 当前 pool 名称,可能影响服务端对堆栈的推断 |
5. 编写自定义 Runner:最小可用示例
5.1 继承 TestRunner 的 Runner 类
文档给出的推荐姿势:从vitest导出的TestRunner派生,这样可以保留快照(snapshot)等依赖 runner 的能力(若做基准测试可同样考虑继承NodeBenchmarkRunner)。
// runner.ts 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 会在 Worker 内new CustomRunner(config),并注入moduleRunner、config(若未设置)以及前面第 3 节所述的 RPC 补丁。
若完全从零实现而不继承TestRunner,importFile的典型实现如下:
export default class Runner { async importFile(filepath: string) { await this.moduleRunner.import(filepath) } }5.2 关键约束(来自官方 warning)
- 必须有 default export,否则 Worker 启动即报错(源码见第 3 节);
- 若没有自定义 runner 或未定义
runTest方法,Vitest 会自动尝试从任务中取出 handler——如果任务在收集时没有通过setFn绑定函数,运行将失败; - 快照等特性依赖 runner:不想丢失快照支持,就继承
TestRunner; - Runner 运行在测试运行时线程中(Worker 内),而不是主线程。
6. 任务模型:File / Suite / Test / TaskResult
理解 Runner 回调参数的关键是任务(task)树。文档明确提示:Runner Tasks API 目前是实验性的,应只在测试运行时使用;在主线程(如 reporter 内)工作时应优先使用 Reported Tasks API(test-module 文档)。
6.1 File:文件的根任务
Vitest 在收集任何测试前先创建一个File任务,它是Suite的超集:
interface File extends Suite { /** 文件所属的 pool 名称,默认 'forks' */ pool?: string /** 文件路径(UNIX 格式) */ filepath: string /** 文件所属的测试项目名 */ projectName: string | undefined /** 收集文件内所有测试的耗时(含导入所有依赖) */ collectDuration?: number /** 导入 setup 文件的耗时 */ setupDuration?: number }file属性挂在每个任务(包括File自身)上,指向文件的根任务。当前仓库的File接口还带有workerId、concurrencyId、importDurations等更多运行时字段(见 runner/types.ts#L307-L359),可供自定义 runner 做性能分析。
6.2 Suite 与 Test
interface Suite extends TaskBase { type: 'suite' /** 文件任务,即文件根任务 */ file: File /** 收集阶段填充的套件内任务数组,适合自顶向下遍历任务树 */ tasks: Task[] }每个任务都有suite属性指回所在套件,适合自底向上遍历;注意顶层直接调用的test/describe没有suite属性(且它不等于file),File也永远没有suite。
interface Test<ExtraContext = object> extends TaskBase { type: 'test' /** 传给测试函数的上下文 */ context: TestContext & ExtraContext /** 文件任务(根任务) */ file: File /** 是否被 context.skip() 跳过 */ pending?: boolean /** 为 true 时,测试失败也算通过 */ fails?: boolean /** 存放 async expect 产生的 promise,测试结束前等待它们 */ promises?: Promise<any>[] }6.3 TaskResult:任务结果
所有任务都可以携带result字段。Suite 仅在套件回调或beforeAll/afterAll抛错导致无法收集测试时才有该字段;测试在回调执行后总是有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 }TaskState的定义为'run' | 'skip' | 'only' | 'todo' | 'queued' | 'pass' | 'fail'(见 runner/types.ts#L12-L13)。
7. 自定义测试函数:createTaskCollector
如果你的 runner 引入的不是test而是自己的任务类型(例如myCustomTask),Vitest 提供createTaskCollector工具来创建自定义的test式 API:行为与普通测试一致,只是收集阶段会调用你提供的自定义方法。任务在收集时被自动加入当前套件(suite.task(name, options))。
// custom.js export { afterAll, beforeAll, describe, TestRunner } from 'vitest' // 此函数在收集阶段被调用: // 不要在这里调用 fn,而是通过 // "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, }) } )测试文件中使用:
// tasks.test.js 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收集到meta.customPropertyToDifferentiateTask的自定义任务即可在你自己的runTask/onBeforeRunTask等钩子中与普通Test区分处理。
8. 使用建议与注意事项
- 定位:
runner是面向库作者的进阶 API;只想跑测试的读者不需要它(官方 warning)。日常场景(自定义钩子、setup/teardown、扩展 matcher)用setupFiles、globalSetup、expect.extend等即可。 - 继承优先:继承
TestRunner(或基准场景的NodeBenchmarkRunner)可以保留快照等内置能力,同时通过覆写runSuite/runTask与生命周期钩子定制执行语义。 - 别忘了两个硬性要求:default export 类构造器 +
importFile方法实现,否则 Worker 初始化阶段就会失败(报错信息见第 3 节源码)。 - 状态上报是免费的:
onTaskUpdate、onCollected、onAfterRunFiles、onAfterRunTask都被框架包了一层 RPC/coverage/bail 逻辑,自定义 Runner 无需重复实现主线程通信。 - API 稳定性:Runner Tasks API 目前是实验性的,官方团队仍在讨论它是否会被 Reported Tasks 取代,跨版本使用自定义 runner 时建议关注 changelog 与接口定义(runner/types.ts)。
- 配置解析:
runner会随 per-project 配置解析并序列化下发(SerializedConfig.runner),因此路径需要能被 Worker 内的moduleRunner.import解析导入,支持 TS/ESM 源文件。
参考路径
- 配置项文档:docs/config/runner.md
- Runner API 文档:docs/api/advanced/runner.md
- 配置类型定义:packages/vitest/src/node/types/config.ts#L776-L779
VitestRunnerConstructor/VitestRunnerImportSource/CancelReason:packages/vitest/src/runtime/runner/types.ts#L1607-L1615- Runner 加载与接管逻辑:packages/vitest/src/runtime/runners/index.ts
- 运行时序列化配置中的
runner字段:packages/vitest/src/runtime/config.ts#L49
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考