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, + }) + }) +})