diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md
index 289186dae..7cf515db0 100644
--- a/docs/guide/test-context.md
+++ b/docs/guide/test-context.md
@@ -1,5 +1,6 @@
---
title: Test Context | Guide
+outline: deep
---
# Test Context
@@ -241,6 +242,70 @@ export default defineWorkspace([
```
:::
+#### Scoping Values to Suite 3.1.0 {#scoping-values-to-suite}
+
+Since Vitest 3.1, you can override context values per suite and its children by using the `test.scoped` API:
+
+```ts
+import { test as baseTest, describe, expect } from 'vitest'
+
+const test = baseTest.extend({
+ dependency: 'default',
+ dependant: ({ dependency }, use) => use({ dependency })
+})
+
+describe('use scoped values', () => {
+ test.scoped({ dependency: 'new' })
+
+ test('uses scoped value', ({ dependant }) => {
+ // `dependant` uses the new overriden value that is scoped
+ // to all tests in this suite
+ expect(dependant).toEqual({ dependency: 'new' })
+ })
+
+ describe('keeps using scoped value', () => {
+ test('uses scoped value', ({ dependant }) => {
+ // nested suite inherited the value
+ expect(dependant).toEqual({ dependency: 'new' })
+ })
+ })
+})
+
+test('keep using the default values', ({ dependant }) => {
+ // the `dependency` is using the default
+ // value outside of the suite with .scoped
+ expect(dependant).toEqual({ dependency: 'default' })
+})
+```
+
+This API is particularly useful if you have a context value that relies on a dynamic variable like a database connection:
+
+```ts
+const test = baseTest.extend<{
+ db: Database
+ schema: string
+}>({
+ db: async ({ schema }, use) => {
+ const db = await createDb({ schema })
+ await use(db)
+ await cleanup(db)
+ },
+ schema: '',
+})
+
+describe('one type of schema', () => {
+ test.scoped({ schema: 'schema-1' })
+
+ // ... tests
+})
+
+describe('another type of schema', () => {
+ test.scoped({ schema: 'schema-2' })
+
+ // ... tests
+})
+```
+
#### TypeScript
To provide fixture types for all your custom contexts, you can pass the fixtures type as a generic.
diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts
index 34a12f4e2..32afc6aad 100644
--- a/packages/runner/src/fixture.ts
+++ b/packages/runner/src/fixture.ts
@@ -1,6 +1,6 @@
import type { FixtureOptions, TestContext } from './types/tasks'
import { createDefer, isObject } from '@vitest/utils'
-import { getFixture } from './map'
+import { getTestFixture } from './map'
export interface FixtureItem extends FixtureOptions {
prop: string
@@ -15,13 +15,36 @@ export interface FixtureItem extends FixtureOptions {
deps?: FixtureItem[]
}
-export function mergeContextFixtures(
+export function mergeScopedFixtures(
+ testFixtures: FixtureItem[],
+ scopedFixtures: FixtureItem[],
+): FixtureItem[] {
+ const scopedFixturesMap = scopedFixtures.reduce>((map, fixture) => {
+ map[fixture.prop] = fixture
+ return map
+ }, {})
+ const newFixtures: Record = {}
+ testFixtures.forEach((fixture) => {
+ const useFixture = scopedFixturesMap[fixture.prop] || {
+ // we need to clone the fixture because we override its values
+ ...fixture,
+ }
+ newFixtures[useFixture.prop] = useFixture
+ })
+ for (const fixtureKep in newFixtures) {
+ const fixture = newFixtures[fixtureKep]
+ // if the fixture was define before the scope, then its dep
+ // will reference the original fixture instead of the scope
+ fixture.deps = fixture.deps?.map(dep => newFixtures[dep.prop])
+ }
+ return Object.values(newFixtures)
+}
+
+export function mergeContextFixtures(
fixtures: Record,
- context: { fixtures?: FixtureItem[] },
+ context: T,
inject: (key: string) => unknown,
-): {
- fixtures?: FixtureItem[]
- } {
+): T {
const fixtureOptionKeys = ['auto', 'injected']
const fixtureArray: FixtureItem[] = Object.entries(fixtures).map(
([prop, value]) => {
@@ -92,7 +115,7 @@ export function withFixtures(fn: Function, testContext?: TestContext) {
return fn({})
}
- const fixtures = getFixture(context)
+ const fixtures = getTestFixture(context)
if (!fixtures?.length) {
return fn(context)
}
diff --git a/packages/runner/src/map.ts b/packages/runner/src/map.ts
index 0fe191814..f2d33e633 100644
--- a/packages/runner/src/map.ts
+++ b/packages/runner/src/map.ts
@@ -4,7 +4,7 @@ import type { Custom, Suite, SuiteHooks, Test, TestContext } from './types/tasks
// use WeakMap here to make the Test and Suite object serializable
const fnMap = new WeakMap()
-const fixtureMap = new WeakMap()
+const testFixtureMap = new WeakMap()
const hooksMap = new WeakMap()
export function setFn(key: Test | Custom, fn: () => Awaitable): void {
@@ -15,15 +15,15 @@ export function getFn(key: Task): () => Awaitable {
return fnMap.get(key as any)
}
-export function setFixture(
+export function setTestFixture(
key: TestContext,
fixture: FixtureItem[] | undefined,
): void {
- fixtureMap.set(key, fixture)
+ testFixtureMap.set(key, fixture)
}
-export function getFixture(key: Context): FixtureItem[] {
- return fixtureMap.get(key as any)
+export function getTestFixture(key: Context): FixtureItem[] {
+ return testFixtureMap.get(key as any)
}
export function setHooks(key: Suite, hooks: SuiteHooks): void {
diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts
index cb04e7619..2b5301662 100644
--- a/packages/runner/src/suite.ts
+++ b/packages/runner/src/suite.ts
@@ -33,8 +33,8 @@ import {
runWithSuite,
withTimeout,
} from './context'
-import { mergeContextFixtures, withFixtures } from './fixture'
-import { getHooks, setFixture, setFn, setHooks } from './map'
+import { mergeContextFixtures, mergeScopedFixtures, withFixtures } from './fixture'
+import { getHooks, setFn, setHooks, setTestFixture } from './map'
import { getCurrentTest } from './test-state'
import { createChainable } from './utils/chain'
@@ -340,7 +340,7 @@ function createSuiteCollector(
value: context,
enumerable: false,
})
- setFixture(context, options.fixtures)
+ setTestFixture(context, options.fixtures)
if (handler) {
setFn(
@@ -395,6 +395,8 @@ function createSuiteCollector(
test.type = 'test'
})
+ let collectorFixtures: FixtureItem[] | undefined
+
const collector: SuiteCollector = {
type: 'collector',
name,
@@ -407,6 +409,19 @@ function createSuiteCollector(
task,
clear,
on: addHook,
+ fixtures() {
+ return collectorFixtures
+ },
+ scoped(fixtures) {
+ const parsed = mergeContextFixtures(
+ fixtures,
+ { fixtures: collectorFixtures },
+ (key: string) => getRunner().injectValue?.(key),
+ )
+ if (parsed.fixtures) {
+ collectorFixtures = parsed.fixtures
+ }
+ },
}
function addHook(name: T, ...fn: SuiteHooks[T]) {
@@ -734,6 +749,11 @@ export function createTaskCollector(
return condition ? this : this.skip
}
+ taskFn.scoped = function (fixtures: Fixtures>) {
+ const collector = getCurrentSuite()
+ collector.scoped(fixtures)
+ }
+
taskFn.extend = function (fixtures: Fixtures>) {
const _context = mergeContextFixtures(
fixtures,
@@ -746,7 +766,15 @@ export function createTaskCollector(
optionsOrFn?: TestOptions | TestFunction,
optionsOrTest?: number | TestOptions | TestFunction,
) {
- getCurrentSuite().test.fn.call(
+ const collector = getCurrentSuite()
+ const scopedFixtures = collector.fixtures()
+ if (scopedFixtures) {
+ this.fixtures = mergeScopedFixtures(
+ this.fixtures || [],
+ scopedFixtures,
+ )
+ }
+ collector.test.fn.call(
this,
formatName(name),
optionsOrFn as TestOptions,
diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts
index 4b3819144..79298dee7 100644
--- a/packages/runner/src/types/tasks.ts
+++ b/packages/runner/src/types/tasks.ts
@@ -468,6 +468,9 @@ export type TestAPI = ChainableTestAPI &
? ExtraContext[K]
: never;
}>
+ scoped: (
+ fixtures: Fixtures>
+ ) => void
}
/** @deprecated use `TestAPI` instead */
@@ -616,6 +619,8 @@ export interface SuiteCollector {
| Test
| SuiteCollector
)[]
+ scoped: (fixtures: Fixtures) => void
+ fixtures: () => FixtureItem[] | undefined
suite?: Suite
task: (name: string, options?: TaskCustomOptions) => Test
collect: (file: File) => Promise
diff --git a/test/core/test/test-extend.test.ts b/test/core/test/test-extend.test.ts
index 1f153f570..2413eb521 100644
--- a/test/core/test/test-extend.test.ts
+++ b/test/core/test/test-extend.test.ts
@@ -384,3 +384,70 @@ describe('asynchronous setup/teardown', () => {
])
})
})
+
+describe('scoping variables to suite', () => {
+ const testAPI = test.extend<{
+ dependency: string
+ pkg: { dependency: string }
+ }>({
+ dependency: 'default',
+ pkg: ({ dependency }, use) => use({ dependency }),
+ })
+
+ testAPI('uses default values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'default' })
+ })
+
+ describe('override dependency', () => {
+ testAPI.scoped({ dependency: 'new' })
+
+ testAPI('uses new values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'new' })
+ })
+
+ describe('nested keeps parent scope', () => {
+ testAPI('keeps using new values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'new' })
+ })
+ })
+
+ describe('override nested overriden scope', () => {
+ testAPI.scoped({ dependency: 'override' })
+
+ testAPI('keeps using new values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'override' })
+ })
+ })
+
+ testAPI('uses new values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'new' })
+ })
+ })
+
+ testAPI('keeps using default values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'default' })
+ })
+
+ describe('override the pkg too', () => {
+ testAPI.scoped({ pkg: { dependency: 'override' } })
+
+ testAPI('uses new values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'override' })
+ })
+ })
+
+ describe('override as dynamic', () => {
+ testAPI.scoped({ dependency: ({}, use) => use('override') })
+
+ testAPI('uses new values', ({ pkg }) => {
+ expect(pkg).toEqual({ dependency: 'override' })
+ })
+ })
+
+ describe.skip('type only', () => {
+ testAPI.scoped({
+ // @ts-expect-error nonExisting is not defined on the testAPI
+ nonExisting: false,
+ })
+ })
+})