Skip to content

Commit e5851e4

Browse files
authoredMar 19, 2025··
feat(runner): add test.scoped to override test.extend fixtures per-suite (#7233)
1 parent 20a5d4b commit e5851e4

File tree

6 files changed

+204
-16
lines changed

6 files changed

+204
-16
lines changed
 

‎docs/guide/test-context.md

+65
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
title: Test Context | Guide
3+
outline: deep
34
---
45

56
# Test Context
@@ -241,6 +242,70 @@ export default defineWorkspace([
241242
```
242243
:::
243244

245+
#### Scoping Values to Suite <Version>3.1.0</Version> {#scoping-values-to-suite}
246+
247+
Since Vitest 3.1, you can override context values per suite and its children by using the `test.scoped` API:
248+
249+
```ts
250+
import { test as baseTest, describe, expect } from 'vitest'
251+
252+
const test = baseTest.extend({
253+
dependency: 'default',
254+
dependant: ({ dependency }, use) => use({ dependency })
255+
})
256+
257+
describe('use scoped values', () => {
258+
test.scoped({ dependency: 'new' })
259+
260+
test('uses scoped value', ({ dependant }) => {
261+
// `dependant` uses the new overriden value that is scoped
262+
// to all tests in this suite
263+
expect(dependant).toEqual({ dependency: 'new' })
264+
})
265+
266+
describe('keeps using scoped value', () => {
267+
test('uses scoped value', ({ dependant }) => {
268+
// nested suite inherited the value
269+
expect(dependant).toEqual({ dependency: 'new' })
270+
})
271+
})
272+
})
273+
274+
test('keep using the default values', ({ dependant }) => {
275+
// the `dependency` is using the default
276+
// value outside of the suite with .scoped
277+
expect(dependant).toEqual({ dependency: 'default' })
278+
})
279+
```
280+
281+
This API is particularly useful if you have a context value that relies on a dynamic variable like a database connection:
282+
283+
```ts
284+
const test = baseTest.extend<{
285+
db: Database
286+
schema: string
287+
}>({
288+
db: async ({ schema }, use) => {
289+
const db = await createDb({ schema })
290+
await use(db)
291+
await cleanup(db)
292+
},
293+
schema: '',
294+
})
295+
296+
describe('one type of schema', () => {
297+
test.scoped({ schema: 'schema-1' })
298+
299+
// ... tests
300+
})
301+
302+
describe('another type of schema', () => {
303+
test.scoped({ schema: 'schema-2' })
304+
305+
// ... tests
306+
})
307+
```
308+
244309
#### TypeScript
245310

246311
To provide fixture types for all your custom contexts, you can pass the fixtures type as a generic.

‎packages/runner/src/fixture.ts

+30-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { FixtureOptions, TestContext } from './types/tasks'
22
import { createDefer, isObject } from '@vitest/utils'
3-
import { getFixture } from './map'
3+
import { getTestFixture } from './map'
44

55
export interface FixtureItem extends FixtureOptions {
66
prop: string
@@ -15,13 +15,36 @@ export interface FixtureItem extends FixtureOptions {
1515
deps?: FixtureItem[]
1616
}
1717

18-
export function mergeContextFixtures(
18+
export function mergeScopedFixtures(
19+
testFixtures: FixtureItem[],
20+
scopedFixtures: FixtureItem[],
21+
): FixtureItem[] {
22+
const scopedFixturesMap = scopedFixtures.reduce<Record<string, FixtureItem>>((map, fixture) => {
23+
map[fixture.prop] = fixture
24+
return map
25+
}, {})
26+
const newFixtures: Record<string, FixtureItem> = {}
27+
testFixtures.forEach((fixture) => {
28+
const useFixture = scopedFixturesMap[fixture.prop] || {
29+
// we need to clone the fixture because we override its values
30+
...fixture,
31+
}
32+
newFixtures[useFixture.prop] = useFixture
33+
})
34+
for (const fixtureKep in newFixtures) {
35+
const fixture = newFixtures[fixtureKep]
36+
// if the fixture was define before the scope, then its dep
37+
// will reference the original fixture instead of the scope
38+
fixture.deps = fixture.deps?.map(dep => newFixtures[dep.prop])
39+
}
40+
return Object.values(newFixtures)
41+
}
42+
43+
export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
1944
fixtures: Record<string, any>,
20-
context: { fixtures?: FixtureItem[] },
45+
context: T,
2146
inject: (key: string) => unknown,
22-
): {
23-
fixtures?: FixtureItem[]
24-
} {
47+
): T {
2548
const fixtureOptionKeys = ['auto', 'injected']
2649
const fixtureArray: FixtureItem[] = Object.entries(fixtures).map(
2750
([prop, value]) => {
@@ -92,7 +115,7 @@ export function withFixtures(fn: Function, testContext?: TestContext) {
92115
return fn({})
93116
}
94117

95-
const fixtures = getFixture(context)
118+
const fixtures = getTestFixture(context)
96119
if (!fixtures?.length) {
97120
return fn(context)
98121
}

‎packages/runner/src/map.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Custom, Suite, SuiteHooks, Test, TestContext } from './types/tasks
44

55
// use WeakMap here to make the Test and Suite object serializable
66
const fnMap = new WeakMap()
7-
const fixtureMap = new WeakMap()
7+
const testFixtureMap = new WeakMap()
88
const hooksMap = new WeakMap()
99

1010
export function setFn(key: Test | Custom, fn: () => Awaitable<void>): void {
@@ -15,15 +15,15 @@ export function getFn<Task = Test | Custom>(key: Task): () => Awaitable<void> {
1515
return fnMap.get(key as any)
1616
}
1717

18-
export function setFixture(
18+
export function setTestFixture(
1919
key: TestContext,
2020
fixture: FixtureItem[] | undefined,
2121
): void {
22-
fixtureMap.set(key, fixture)
22+
testFixtureMap.set(key, fixture)
2323
}
2424

25-
export function getFixture<Context = TestContext>(key: Context): FixtureItem[] {
26-
return fixtureMap.get(key as any)
25+
export function getTestFixture<Context = TestContext>(key: Context): FixtureItem[] {
26+
return testFixtureMap.get(key as any)
2727
}
2828

2929
export function setHooks(key: Suite, hooks: SuiteHooks): void {

‎packages/runner/src/suite.ts

+32-4
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import {
3333
runWithSuite,
3434
withTimeout,
3535
} from './context'
36-
import { mergeContextFixtures, withFixtures } from './fixture'
37-
import { getHooks, setFixture, setFn, setHooks } from './map'
36+
import { mergeContextFixtures, mergeScopedFixtures, withFixtures } from './fixture'
37+
import { getHooks, setFn, setHooks, setTestFixture } from './map'
3838
import { getCurrentTest } from './test-state'
3939
import { createChainable } from './utils/chain'
4040

@@ -340,7 +340,7 @@ function createSuiteCollector(
340340
value: context,
341341
enumerable: false,
342342
})
343-
setFixture(context, options.fixtures)
343+
setTestFixture(context, options.fixtures)
344344

345345
if (handler) {
346346
setFn(
@@ -395,6 +395,8 @@ function createSuiteCollector(
395395
test.type = 'test'
396396
})
397397

398+
let collectorFixtures: FixtureItem[] | undefined
399+
398400
const collector: SuiteCollector = {
399401
type: 'collector',
400402
name,
@@ -407,6 +409,19 @@ function createSuiteCollector(
407409
task,
408410
clear,
409411
on: addHook,
412+
fixtures() {
413+
return collectorFixtures
414+
},
415+
scoped(fixtures) {
416+
const parsed = mergeContextFixtures(
417+
fixtures,
418+
{ fixtures: collectorFixtures },
419+
(key: string) => getRunner().injectValue?.(key),
420+
)
421+
if (parsed.fixtures) {
422+
collectorFixtures = parsed.fixtures
423+
}
424+
},
410425
}
411426

412427
function addHook<T extends keyof SuiteHooks>(name: T, ...fn: SuiteHooks[T]) {
@@ -734,6 +749,11 @@ export function createTaskCollector(
734749
return condition ? this : this.skip
735750
}
736751

752+
taskFn.scoped = function (fixtures: Fixtures<Record<string, any>>) {
753+
const collector = getCurrentSuite()
754+
collector.scoped(fixtures)
755+
}
756+
737757
taskFn.extend = function (fixtures: Fixtures<Record<string, any>>) {
738758
const _context = mergeContextFixtures(
739759
fixtures,
@@ -746,7 +766,15 @@ export function createTaskCollector(
746766
optionsOrFn?: TestOptions | TestFunction,
747767
optionsOrTest?: number | TestOptions | TestFunction,
748768
) {
749-
getCurrentSuite().test.fn.call(
769+
const collector = getCurrentSuite()
770+
const scopedFixtures = collector.fixtures()
771+
if (scopedFixtures) {
772+
this.fixtures = mergeScopedFixtures(
773+
this.fixtures || [],
774+
scopedFixtures,
775+
)
776+
}
777+
collector.test.fn.call(
750778
this,
751779
formatName(name),
752780
optionsOrFn as TestOptions,

‎packages/runner/src/types/tasks.ts

+5
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,9 @@ export type TestAPI<ExtraContext = object> = ChainableTestAPI<ExtraContext> &
468468
? ExtraContext[K]
469469
: never;
470470
}>
471+
scoped: (
472+
fixtures: Fixtures<Partial<ExtraContext>>
473+
) => void
471474
}
472475

473476
/** @deprecated use `TestAPI` instead */
@@ -616,6 +619,8 @@ export interface SuiteCollector<ExtraContext = object> {
616619
| Test<ExtraContext>
617620
| SuiteCollector<ExtraContext>
618621
)[]
622+
scoped: (fixtures: Fixtures<any, ExtraContext>) => void
623+
fixtures: () => FixtureItem[] | undefined
619624
suite?: Suite
620625
task: (name: string, options?: TaskCustomOptions) => Test<ExtraContext>
621626
collect: (file: File) => Promise<Suite>

‎test/core/test/test-extend.test.ts

+67
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,70 @@ describe('asynchronous setup/teardown', () => {
384384
])
385385
})
386386
})
387+
388+
describe('scoping variables to suite', () => {
389+
const testAPI = test.extend<{
390+
dependency: string
391+
pkg: { dependency: string }
392+
}>({
393+
dependency: 'default',
394+
pkg: ({ dependency }, use) => use({ dependency }),
395+
})
396+
397+
testAPI('uses default values', ({ pkg }) => {
398+
expect(pkg).toEqual({ dependency: 'default' })
399+
})
400+
401+
describe('override dependency', () => {
402+
testAPI.scoped({ dependency: 'new' })
403+
404+
testAPI('uses new values', ({ pkg }) => {
405+
expect(pkg).toEqual({ dependency: 'new' })
406+
})
407+
408+
describe('nested keeps parent scope', () => {
409+
testAPI('keeps using new values', ({ pkg }) => {
410+
expect(pkg).toEqual({ dependency: 'new' })
411+
})
412+
})
413+
414+
describe('override nested overriden scope', () => {
415+
testAPI.scoped({ dependency: 'override' })
416+
417+
testAPI('keeps using new values', ({ pkg }) => {
418+
expect(pkg).toEqual({ dependency: 'override' })
419+
})
420+
})
421+
422+
testAPI('uses new values', ({ pkg }) => {
423+
expect(pkg).toEqual({ dependency: 'new' })
424+
})
425+
})
426+
427+
testAPI('keeps using default values', ({ pkg }) => {
428+
expect(pkg).toEqual({ dependency: 'default' })
429+
})
430+
431+
describe('override the pkg too', () => {
432+
testAPI.scoped({ pkg: { dependency: 'override' } })
433+
434+
testAPI('uses new values', ({ pkg }) => {
435+
expect(pkg).toEqual({ dependency: 'override' })
436+
})
437+
})
438+
439+
describe('override as dynamic', () => {
440+
testAPI.scoped({ dependency: ({}, use) => use('override') })
441+
442+
testAPI('uses new values', ({ pkg }) => {
443+
expect(pkg).toEqual({ dependency: 'override' })
444+
})
445+
})
446+
447+
describe.skip('type only', () => {
448+
testAPI.scoped({
449+
// @ts-expect-error nonExisting is not defined on the testAPI
450+
nonExisting: false,
451+
})
452+
})
453+
})

0 commit comments

Comments
 (0)
Please sign in to comment.