ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vitest 测试上下文完全指南:内置上下文、test.extend 夹具与源码级实现解析

2026/9/14 16:53:59 拓冰建站 浏览量
Vitest 测试上下文完全指南:内置上下文、test.extend 夹具与源码级实现解析 Vitest 测试上下文完全指南内置上下文、test.extend 夹具与源码级实现解析【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitestVitest 的测试上下文Test Context借鉴自 Playwright Fixtures为每个测试回调注入task、expect、skip、signal、bench等内置能力并允许通过test.extend定义可自动初始化、按作用域共享、自动清理的自定义夹具。读完本篇你将掌握测试上下文的全部内置 API、Builder 模式与 Playwright 兼容对象语法两套扩展方式、test/file/worker三级作用域规则并能对照 Vitest 仓库源码理解夹具解析、惰性初始化与清理执行的底层机制。测试上下文测试回调的第一个参数Vitest 中每个测试回调的第一个参数就是测试上下文它携带当前测试的元数据与一组可执行操作import { it } from vitest it(should work, ({ task }) { // prints name of the test console.log(task.name) })在源码中上下文的组装入口是 createTestContext它把测试任务对象挂到context.task上并填充skip、annotate、onTestFailed、onTestFinished等方法。随后 TestRunner.extendTaskContext 以Object.defineProperty惰性注入expect与bench——只有真正访问时才创建ExpectStatic实例createExpect(context.task)与Bench实例避免每个测试都付出构造开销。内置测试上下文以下字段由运行器内置提供fixture.ts 中的TestFixtures._builtinFixtures常量完整列出了这份名单task、signal、onTestFailed、onTestFinished、skip、annotate、bench。task一个只读对象包含当前测试的元数据名称、所属套件等可直接用于日志输出或诊断it(should work, ({ task }) { console.log(task.name) })expect绑定到当前测试的expectAPIimport { it } from vitest it(math is easy, ({ expect }) { expect(2 2).toBe(4) })这个 API 的价值在于并发快照测试全局expect无法在并发执行时正确追踪每个测试的快照归属而上下文的expect与当前任务绑定import { it } from vitest it.concurrent(math is easy, ({ expect }) { expect(2 2).toMatchInlineSnapshot() }) it.concurrent(math is hard, ({ expect }) { expect(2 * 2).toMatchInlineSnapshot() })skipfunction skip(note?: string): never function skip(condition: boolean, note?: string): void跳过后续测试执行并将测试标记为跳过import { expect, it } from vitest it(math is hard, ({ skip }) { skip() expect(2 2).toBe(5) })自 Vitest 3.1 起skip接受布尔参数实现条件跳过it(math is hard, ({ skip, mind }) { skip(mind foggy) expect(2 2).toBe(5) })从 context.ts 的实现可以看到其机制skip会把test.result置为skip状态并抛出PendingError因此后续代码包括未受保护的断言不会真正执行条件为false时则什么都不做、直接返回。annotateVitest 3.2.0function annotate( message: string, attachment?: TestAttachment, ): PromiseTestAnnotation function annotate( message: string, type?: string, attachment?: TestAttachment, ): PromiseTestAnnotation为测试添加一条会在 reporter 中展示的测试注解test(annotations API, async ({ annotate }) { await annotate(https://github.com/vitest-dev/vitest/pull/7953, issues) })实现上context.tsannotate会校验测试处于run状态、把注解记录为内部工件再通过runner.onTestAnnotate解析后推入test.annotations供 reporter 消费。signalVitest 3.2.0一个标准的AbortSignal可由 Vitest 主动中止。触发中止的场景包括测试超时test times out用户通过 CtrlC 手动取消测试运行程序化调用了vitest.cancelCurrentRun并行执行的另一个测试失败且设置了bail标志it(stop request when test times out, async ({ signal }) { await fetch(/resource, { signal }) }, 2000)从源码看每个上下文持有一个AbortControllercontext.ts 中的abortControllersWeakMap超时经abortIfTimeout调用abortContextSignal触发中止从而让fetch、WebSocket 等遵循AbortSignal的 API 自动停止。benchVitest 5.0.0bench夹具允许在普通测试中定义并运行基准测试用于测量吞吐量、对比实现并断言相对性能import { expect, test } from vitest test(compare parsers, async ({ bench }) { const result await bench.compare( bench(JSON.parse, () { JSON.parse({key:value}) }), bench(custom parser, () { customParse({key:value}) }), ) expect(result.get(JSON.parse)).toBeFasterThan(result.get(custom parser)) })bench在 extendTaskContext 中是惰性 getter首次访问才调用createBench(context.task, runnerConfig, moduleRunner)并缓存到benchInstances。完整文档参见 Benchmarks 指南。onTestFailed/onTestFinishedonTestFailed与onTestFinished是绑定到当前测试的钩子适用于并发测试中只为某个特定测试做特殊处理的场景。实现上context.ts两个方法把回调包装进withTimeout默认超时取hookTimeout配置后分别推入test.onFailed与test.onFinished数组。扩展测试上下文test.extendVitest 允许通过test.extend把自定义工具、状态与夹具注入测试上下文。test.extend支持两种语法Builder 模式官方推荐自动推导类型与对象语法Playwright 兼容可一次性定义全部夹具。Builder 模式Vitest 4.1.0Builder 模式是推荐的夹具定义方式它提供自动类型推导——TypeScript 直接从返回值推断每个夹具的类型无需手工声明import { test as baseTest } from vitest export const test baseTest // Simple value - type is inferred as { port: number; host: string } .extend(config, { port: 3000, host: localhost }) // Function fixture - type is inferred from return value .extend(server, async ({ config }) { // TypeScript knows config is { port: number; host: string } return http://${config.host}:${config.port} })在测试中使用import { expect } from vitest import { test } from ./my-test.js test(server uses correct port, ({ config, server }) { // TypeScript knows the types: // - config is { port: number; host: string } // - server is string expect(server).toBe(http://localhost:3000) expect(config.port).toBe(3000) })使用onCleanup做 Setup 与清理需要初始化/销毁逻辑的夹具应写成函数用onCleanup回调注册在作用域结束后执行的清理逻辑import { test as baseTest } from vitest export const test baseTest .extend(tempFile, async ({}, { onCleanup }) { const filePath /tmp/test-${Date.now()}.txt await fs.writeFile(filePath, test data) // Register cleanup - runs after test completes onCleanup(async () { await fs.unlink(filePath) }) return filePath })更复杂的级联示例const test baseTest .extend(database, { scope: file }, async ({}, { onCleanup }) { const db await createDatabase() await db.connect() onCleanup(async () { await db.disconnect() }) return db }) .extend(user, async ({ database }, { onCleanup }) { const user await database.createTestUser() onCleanup(async () { await database.deleteUser(user.id) }) return user })注意onCleanup每个夹具只能调用一次。若需要多个清理操作要么合并进单个清理函数要么把夹具拆分为多个更小的夹具。拆开是推荐做法隔离性更好、依赖也更显式// ❌ This will throw an error const test baseTest .extend(resources, async ({}, { onCleanup }) { const a await acquireA() onCleanup(() releaseA(a)) const b await acquireB() onCleanup(() releaseB(b)) // Error: onCleanup can only be called once return { a, b } }) // ✅ Split into separate fixtures (recommended) const test baseTest .extend(resourceA, async ({}, { onCleanup }) { const a await acquireA() onCleanup(() releaseA(a)) return a }) .extend(resourceB, async ({}, { onCleanup }) { const b await acquireB() onCleanup(() releaseB(b)) return b })夹具选项.extend()的第二个参数接受选项const test baseTest // Automatic fixture - runs for every test even if not used .extend(metrics, { auto: true }, ({}, { onCleanup }) { const metrics new MetricsCollector() metrics.start() onCleanup(() metrics.stop()) return metrics }) // Worker-scoped fixture - initialized once per worker .extend(config, { scope: worker }, () { return loadConfig() }) // File-scoped fixture - initialized once per file .extend(database, { scope: file }, async ({ config }, { onCleanup }) { const db await createDatabase(config) onCleanup(() db.close()) return db }) // Injected fixture - can be overridden via config .extend(baseUrl, { injected: true }, () { return http://localhost:3000 })测试作用域默认的夹具可以省略选项const test baseTest .extend(simple, () value)从源码看TestFixtures.parseUserFixtures 会把每个夹具归一化为{ auto, injected, scope }三个布尔/枚举字段未指定选项时默认{ auto: false, injected: false, scope: test }并对同名夹具的重复注册做校验——若scope或auto与已有注册不一致会抛出FixtureDependencyError。访问其他夹具每个夹具可通过第一个参数访问此前定义的夹具函数式与非函数式夹具均适用const test baseTest .extend(config, { apiUrl: https://api.example.com, port: 3000 }) .extend(client, ({ config }) { // TypeScript knows config is { apiUrl: string; port: number } return new ApiClient(config.apiUrl) }) .extend(user, async ({ client }) { // TypeScript knows client is ApiClient return await client.getCurrentUser() })对象语法Playwright 兼容如果你正从 Playwright 迁移或偏好一次性定义所有夹具Vitest 也支持 Playwright 兼容的对象语法import { test as baseTest } from vitest export const test baseTest.extend({ page: async ({}, use) { // setup the fixture before each test function const page await browser.newPage() // use the fixture value await use(page) // cleanup the fixture after each test function await page.close() }, baseUrl: http://localhost:3000 })与 Builder 模式的关键区别在于清理方式——对象语法使用use()回调// Object syntax: cleanup code goes AFTER use() const test baseTest.extend({ database: async ({}, use) { const db await createDatabase() await db.connect() await use(db) // Test runs here // Cleanup after the test await db.disconnect() } }) // Builder pattern: cleanup is registered with onCleanup() const test baseTest .extend(database, async ({}, { onCleanup }) { const db await createDatabase() await db.connect() onCleanup(() db.disconnect()) return db // Test runs after this returns })类型提示由于 TypeScript 无法从use()回调推断类型使用对象语法时需要以泛型参数手工提供类型const test baseTest.extend{ page: Page baseUrl: string }({ page: async ({}, use) { const page await browser.newPage() await use(page) await page.close() }, baseUrl: http://localhost:3000 })源码印证对象语法中的use()机制在 resolveFixtureFunction 中实现——它用一个useFnArgPromise从use(arg)调用中提取夹具值同时挂起测试执行直到清理阶段才恢复如果夹具函数返回时从未调用use会抛出Fixture xxx returned without calling use错误。这也解释了为什么use()必须在每条代码路径上调用。选项的元组语法对象语法下用元组[fixtureFn, options]指定夹具选项const test baseTest.extend({ // Auto fixture fixture: [ async ({}, use) { setup() await use() teardown() }, { auto: true } ], // Scoped fixture database: [ async ({}, use) { const db await createDatabase() await use(db) await db.close() }, { scope: file } ], // Injected fixture url: [ /default, { injected: true } ], })夹具的惰性初始化Vitest 运行器会基于实际使用智能地初始化夹具并注入测试上下文import { test as baseTest } from vitest const test baseTest .extend(database, async () { console.log(database initializing) return createDatabase() }) .extend(cache, async () { return createCache() }) // database will not run test(no fixtures needed, () {}) test(only cache, ({ cache }) {}) // database will run test(needs database, ({ database }) {})注意使用test.extend()定义夹具后无论在夹具函数还是测试函数中都应始终使用对象解构{ database }来访问上下文test(context must be destructured, (context) { // [!code --] expect(context.database).toBeDefined() }) test(context must be destructured, ({ database }) { // [!code ] expect(database).toBeDefined() })源码印证惰性初始化依赖静态分析——getUsedProps 解析回调函数源码的第一个参数提取解构出的属性名集合因此强制要求对象解构写法非解构参数会抛出FixtureParseErrorrest 参数同样不支持依赖闭包则通过 resolveDeps 做拓扑排序遇到循环依赖时抛出Circular fixture dependency detected错误。对应测试用例见 test/e2e/fixtures/fails/test-extend/ 与 test/unit/test/fixture-initialization.test.ts。扩展已扩展的测试可以在已扩展的测试上继续添加夹具import { test as dbTest } from ./my-test.js export const test dbTest .extend(user, ({ database }) { return database.createUser() })对象语法同样支持import { test as dbTest } from ./my-test.js export const test dbTest.extend({ admin: async ({ database }, use) { const admin await database.createAdmin() await use(admin) await database.deleteUser(admin.id) } })混用两种语法Builder 模式可以链式接在对象扩展之后const test baseTest // Object syntax for simple fixtures .extend{ apiKey: string }({ apiKey: test-key-123, }) // Builder pattern for complex fixtures with inference .extend(client, ({ apiKey }) { // TypeScript knows apiKey is string return new ApiClient(apiKey) })夹具作用域Vitest 3.2.0默认情况下夹具按测试初始化可用scope选项在测试之间共享夹具。注意未显式指定作用域的夹具一律视为test作用域这意味着它不能被worker和file作用域夹具使用。若需跨作用域访问请手动指定 scopetest .extend(port, { scope: worker }, 5000) .extend(db, { scope: worker }, async ({ port }) { return createDb(port) })另外非test作用域的夹具不能直接在describe块内覆盖test.override(port, { scope: worker }, 3000)会报错——应在模块顶层覆盖或使用injected选项在 project 配置 中提供值。还要注意在 non-isolate 模式下覆盖worker夹具会影响其后所有在同一 worker 中运行的测试文件。Test 作用域默认Test 作用域夹具为每个测试创建全新实例const test baseTest .extend(counter, () { return { value: 0 } }) test(first test, ({ counter }) { counter.value expect(counter.value).toBe(1) }) test(second test, ({ counter }) { // Fresh instance, value is 0 again expect(counter.value).toBe(0) })Test 作用域夹具可访问全部内置测试上下文task、expect、skip等const test baseTest .extend(testInfo, ({ task }) { return { name: task.name } })File 作用域File 作用域夹具在每个测试文件内只初始化一次const test baseTest .extend(database, { scope: file }, async ({}, { onCleanup }) { const db await createDatabase() onCleanup(() db.close()) return db }) test(first test, ({ database }) { // Uses the same database instance }) test(second test, ({ database }) { // Same database instance as first test })Worker 作用域Worker 作用域夹具在每个 worker 进程内只初始化一次const test baseTest .extend(config, { scope: worker }, () { return await loadExpensiveConfig() })默认每个文件运行在独立 worker 中所以file与worker作用域效果相同若关闭 isolationworker 数量受maxWorkers限制worker 作用域夹具会在同一 worker 中运行的多个文件间共享。在vmThreads或vmForks池中由于每个文件拥有独立 VM 上下文scope: worker的等效行为退化为scope: file——这一点在源码中体现得很直接fixture.ts 在注册时发现runner.pool vmThreads || runner.pool vmForks会直接把item.scope改为file。作用域层级夹具只能访问**同级或更高级生命周期更长**作用域的夹具夹具作用域可访问worker仅其他 worker 夹具fileworker file 夹具testworker file test 夹具 内置测试上下文const test baseTest .extend(config, { scope: worker }, () { return { apiUrl: https://api.example.com } }) .extend(database, { scope: file }, async ({ config }, { onCleanup }) { // ✅ File fixture can access worker fixture const db await createDatabase(config.apiUrl) onCleanup(() db.close()) return db }) .extend(user, async ({ database, task }) { // ✅ Test fixture can access file fixture AND test context return await database.createUser(task.name) })提示只有 test 作用域夹具能访问内置测试上下文task、expect、skip等因为 worker/file 夹具运行在任何具体测试之外。如果 file 作用域夹具需要文件路径请改用expect.getState().testPath。该层级规则由 parseUserFixtures 的依赖校验 在注册期强制当fixture.scope在作用序中的索引小于其依赖dep.scope时抛出FixtureDependencyErrorcannot depend on a xxx fixture。相关端到端验证见 test/e2e/test/scoped-fixtures.test.ts。类型安全的作用域访问Vitest 3.2.0Builder 模式下TypeScript 自动强制执行基于作用域的访问规则——尝试从 file 作用域夹具访问 test 作用域夹具会在编译期报错。如果你使用对象语法并希望获得同样的类型安全可以通过$worker、$file、$test键显式声明各夹具归属的作用域const test baseTest.extend{ $worker: { config: Config } $file: { database: Database } $test: { user: User } }({ config: [async ({}, use) { await use(loadConfig()) }, { scope: worker }], database: [async ({ config }, use) { const db await createDatabase(config) await use(db) await db.close() }, { scope: file }], user: async ({ database }, use) { const user await database.createUser() await use(user) await database.deleteUser(user.id) }, })这与 Builder 模式提供同等的编译期安全把作用域违例从运行时提前到构建时捕获。注入夹具Injected自 Vitest 3 起你可以为不同 project 提供不同的夹具值。启用方式为在选项中传{ injected: true }——若 project 配置 中未指定该键则使用默认值import { test as baseTest } from vitest const test baseTest .extend(url, { injected: true }, /default) test(works correctly, ({ url }) { // url is /default in project-new // url is /full in project-full // url is /empty in project-empty })import { defineConfig } from vitest/config export default defineConfig({ test: { projects: [ { test: { name: project-new, }, }, { test: { name: project-full, provide: { url: /full, }, }, }, { test: { name: project-empty, provide: { url: /empty, }, }, }, ], }, })实现上元组形式的注入夹具在 parseUserFixtures 中优先取runner.injectValue?.(name)由 TestRunner.injectValue 桥接到inject机制取不到才回退到默认值。覆盖夹具值Vitest 4.1.0可以使用test.override为特定套件含其子级覆盖夹具值适用于不同测试场景需要不同夹具值的场合。提示覆盖时若未提供选项Vitest 会自动继承原选项。但不能覆盖夹具的scope或auto选项。Builder 模式推荐import { test as baseTest, describe, expect } from vitest const test baseTest .extend(config, { port: 3000, host: localhost }) .extend(server, ({ config }) http://${config.host}:${config.port}) describe(production environment, () { // Override with a new static value (chainable) test .override(config, { port: 8080, host: api.example.com }) test(uses production config, ({ server }) { expect(server).toBe(http://api.example.com:8080) }) }) describe(with custom server, () { // Override with a function that can access other fixtures test.override(server, ({ config }) { return https://${config.host}:${config.port}/v2 }) test(uses custom server, ({ server }) { expect(server).toBe(https://localhost:3000/v2) }) }) test(uses default values, ({ server }) { expect(server).toBe(http://localhost:3000) })链式多重覆盖test.override返回测试 API 本身可以连续链式调用describe(production environment, () { test .override(environment, production) .override(port, 8080) .override(debug, false) test(uses production settings, ({ environment, port, debug }) { expect(environment).toBe(production) expect(port).toBe(8080) expect(debug).toBe(false) }) })对象语法覆盖也可以一次性覆盖多个夹具describe(different configuration, () { test.override({ config: { port: 4000, host: test.local }, }) test(uses overwritten config, ({ config }) { expect(config.port).toBe(4000) }) })带清理的覆盖以函数覆盖时同样可以使用onCleanupdescribe(with custom database, () { test.override(database, async ({ config }, { onCleanup }) { const db await createTestDatabase(config) onCleanup(() db.drop()) return db }) test(uses custom database, ({ database }) { // Uses the overwritten database }) })嵌套作用域继承覆盖会被嵌套套件继承且可再次被覆盖describe(level 1, () { test.override(value, one) test(uses level 1 value, ({ value }) { expect(value).toBe(one) }) describe(level 2, () { test.override(value, two) test(uses level 2 value, ({ value }) { expect(value).toBe(two) }) }) test(still uses level 1 value, ({ value }) { expect(value).toBe(one) }) })注意test.override中不能引入新夹具新增夹具请用test.extend。test.scoped已弃用并被test.override取代旧 API 仍可用但将在未来版本移除。源码印证覆盖的解析逻辑见 TestFixtures.override——它复制最近父级的注册表再解析新夹具套件级覆盖存入this._overridesWeakMap 并按最近优先被 get 沿套件链查找这正对应嵌套继承、就近生效的语义。类型安全钩子使用test.extend后扩展得到的test对象提供感知扩展上下文的类型安全钩子const test baseTest .extend(counter, { value: 0, increment() { this.value } }) // Unlike global hooks, these hooks are aware of the extended context test.beforeEach(({ counter }) { counter.increment() }) test.afterEach(({ counter }) { console.log(Final count:, counter.value) })套件级钩子与夹具Vitest 4.1.0扩展后的test对象还提供可访问 file/worker 作用域夹具的beforeAll、afterAll与aroundAll钩子const test baseTest .extend(config, { scope: file }, () loadConfig()) .extend(database, { scope: file }, async ({ config }, { onCleanup }) { const db await createDatabase(config) onCleanup(() db.close()) return db }) // Access file-scoped fixtures in suite-level hooks test.aroundAll(async (runSuite, { database }) { await database.transaction(runSuite) }) test.beforeAll(async ({ database }) { await database.createUsers() }) test.afterAll(async ({ database }) { await database.removeUsers() })重要套件级钩子beforeAll、afterAll、aroundAll必须调用在test.extend()返回的test对象上才能访问扩展夹具。使用全局beforeAll/afterAll/aroundAll函数是访问不到自定义夹具的import { test as baseTest, beforeAll } from vitest const test baseTest .extend(database, { scope: file }, async ({}, { onCleanup }) { const db await createDatabase() onCleanup(() db.close()) return db }) // ❌ WRONG: Global beforeAll doesnt have access to database beforeAll(({ database }) { // Error: database is undefined }) // ✅ CORRECT: Use test.beforeAll to access fixtures test.beforeAll(({ database }) { // database is available })此规则同样适用于beforeAll、afterAll、aroundAll全部套件级钩子。提示套件级钩子只能访问file 作用域与 worker 作用域夹具含auto夹具test 作用域夹具在这些钩子中不可用强行访问会抛出错误。例如const test baseTest .extend(testFixture, () test-scoped) .extend(fileFixture, { scope: file }, () file-scoped) // ❌ Error: test-scoped fixtures not available in beforeAll test.beforeAll(({ testFixture }) {}) // ✅ Works: file-scoped fixtures are available test.beforeAll(({ fileFixture }) {})源码印证该限制由 withFixtures 在运行期强制——检测到套件钩子解析出scope test的夹具时抛出FixtureDependencyError并提示改用file/worker作用域或迁移到beforeEach等Each钩子。若全局钩子误用了夹具validateSuiteHook 会抛出FixtureAccessError明确提示Did you forget to call it as test.beforeAll() instead of beforeAll()?。相关测试覆盖见 test/unit/test/fixture-concurrent.test.ts、test/unit/test/test-extend.test.ts 与 test/unit/test/test-extend-with-top-level-hooks.test.ts。小结Vitest 测试上下文把测试需要什么从全局工具函数收拢到回调参数中内置上下文task、expect、skip、annotate、signal、bench、onTestFailed/onTestFinished覆盖并发快照、优雅取消与注解上报等高频场景test.extend则以 Playwright 式夹具模型提供自动初始化、依赖解析、onCleanup清理与三级作用域共享配合injected与test.override进一步支持多 project 差异化配置与套件级覆盖。结合 fixture.ts、context.ts 与 runners/test.ts 的源码可以完整理解按需初始化、作用域校验、惰性 expect/bench背后的实现机制并据此在自己的测试基建中设计可复用的夹具模块。【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考