Skip to content

Commit e5851e4

Browse files
authored
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

Lines changed: 65 additions & 0 deletions
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

Lines changed: 30 additions & 7 deletions
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

Lines changed: 5 additions & 5 deletions
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

Lines changed: 32 additions & 4 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 67 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy