diff --git a/docs/api/index.md b/docs/api/index.md index 2ea9aa18f96a..a9d43126a023 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -70,7 +70,7 @@ In Jest, `TestFunction` can also be of type `(done: DoneCallback) => void`. If t const archive = [] const myTest = test.extend({ - todos: async (use) => { + todos: async ({ task }, use) => { todos.push(1, 2, 3) await use(todos) todos.length = 0 diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index 0aa057497a5a..ac1c7a5ee53c 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -51,7 +51,7 @@ const todos = [] const archive = [] export const myTest = test.extend({ - todos: async (use) => { + todos: async ({ task }, use) => { // setup the fixture before each test function todos.push(1, 2, 3) @@ -105,7 +105,7 @@ Vitest runner will smartly initialize your fixtures and inject them into the tes ```ts import { test } from 'vitest' -async function todosFn(use) { +async function todosFn({ task }, use) { await use([1, 2, 3]) } @@ -115,15 +115,17 @@ const myTest = test.extend({ }) // todosFn will not run -myTest('', () => {}) // no fixture is available -myTets('', ({ archive }) => {}) // only archive is available +myTest('', () => {}) +myTets('', ({ archive }) => {}) // todosFn will run -myTest('', ({ todos }) => {}) // only todos is available -myTest('', (context) => {}) // both are available -myTest('', ({ archive, ...rest }) => {}) // both are available +myTest('', ({ todos }) => {}) ``` +::: warning +When using `test.extend()` with fixtures, you should always use the object destructuring pattern `{ todos }` to access context both in fixture function and test function. +::: + #### 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 3f88e61009d0..53db170f1c83 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -1,66 +1,141 @@ -import type { Fixtures, Test } from './types' +import type { TestContext } from './types' + +export interface FixtureItem { + prop: string + value: any + index: number + /** + * Indicates whether the fixture is a function + */ + isFn: boolean + /** + * The dependencies(fixtures) of current fixture function. + */ + deps?: FixtureItem[] +} + +export function mergeContextFixtures(fixtures: Record, context: { fixtures?: FixtureItem[] } = {}) { + const fixtureArray: FixtureItem[] = Object.entries(fixtures) + .map(([prop, value], index) => { + const isFn = typeof value === 'function' + return { + prop, + value, + index, + isFn, + } + }) + + if (Array.isArray(context.fixtures)) + context.fixtures = context.fixtures.concat(fixtureArray) + else + context.fixtures = fixtureArray + + // Update dependencies of fixture functions + fixtureArray.forEach((fixture) => { + if (fixture.isFn) { + const usedProps = getUsedProps(fixture.value) + if (usedProps.length) + fixture.deps = context.fixtures!.filter(({ index, prop }) => index !== fixture.index && usedProps.includes(prop)) + } + }) -export function withFixtures(fn: Function, fixtures: Fixtures>, context: Test>['context']) { - const props = getUsedFixtureProps(fn, Object.keys(fixtures)) + return context +} - if (props.length === 0) +export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record) { + if (!fixtures.length) return () => fn(context) + const usedProps = getUsedProps(fn) + if (!usedProps.length) + return () => fn(context) + + const usedFixtures = fixtures.filter(({ prop }) => usedProps.includes(prop)) + const pendingFixtures = resolveDeps(usedFixtures) let cursor = 0 async function use(fixtureValue: any) { - context[props[cursor++]] = fixtureValue - - if (cursor < props.length) + const { prop } = pendingFixtures[cursor++] + context[prop] = fixtureValue + if (cursor < pendingFixtures.length) await next() else await fn(context) } async function next() { - const fixtureValue = fixtures[props[cursor]] - typeof fixtureValue === 'function' - ? await fixtureValue(use) - : await use(fixtureValue) + const { value } = pendingFixtures[cursor] + typeof value === 'function' ? await value(context, use) : await use(value) } return () => next() } -function getUsedFixtureProps(fn: Function, fixtureProps: string[]) { - if (!fixtureProps.length || !fn.length) - return [] +function resolveDeps(fixtures: FixtureItem[], depSet = new Set(), pendingFixtures: FixtureItem[] = []) { + fixtures.forEach((fixture) => { + if (pendingFixtures.includes(fixture)) + return + if (!fixture.isFn || !fixture.deps) { + pendingFixtures.push(fixture) + return + } + if (depSet.has(fixture)) + throw new Error('circular fixture dependency') - const paramsStr = fn.toString().match(/[^(]*\(([^)]*)/)![1] + depSet.add(fixture) + resolveDeps(fixture.deps, depSet, pendingFixtures) + pendingFixtures.push(fixture) + depSet.clear() + }) - if (paramsStr[0] === '{' && paramsStr.at(-1) === '}') { - // ({...}) => {} - const props = paramsStr.slice(1, -1).split(',') - const filteredProps = [] + return pendingFixtures +} - for (const prop of props) { - if (!prop) - continue +function getUsedProps(fn: Function) { + const match = fn.toString().match(/[^(]*\(([^)]*)/) + if (!match) + return [] - let _prop = prop.trim() + const args = splitByComma(match[1]) + if (!args.length) + return [] - if (_prop.startsWith('...')) { - // ({ a, b, ...rest }) => {} - return fixtureProps - } + const first = args[0] + if (!(first.startsWith('{') && first.endsWith('}'))) + throw new Error('the first argument must use object destructuring pattern') - const colonIndex = _prop.indexOf(':') - if (colonIndex > 0) - _prop = _prop.slice(0, colonIndex).trim() + const _first = first.slice(1, -1).replace(/\s/g, '') + const props = splitByComma(_first).map((prop) => { + return prop.replace(/\:.*|\=.*/g, '') + }) - if (fixtureProps.includes(_prop)) - filteredProps.push(_prop) - } + const last = props.at(-1) + if (last && last.startsWith('...')) + throw new Error('Rest parameters are not supported') - // ({}) => {} - // ({ a, b, c}) => {} - return filteredProps - } + return props +} - // (ctx) => {} - return fixtureProps +function splitByComma(s: string) { + const result = [] + const stack = [] + let start = 0 + for (let i = 0; i < s.length; i++) { + if (s[i] === '{' || s[i] === '[') { + stack.push(s[i] === '{' ? '}' : ']') + } + else if (s[i] === stack[stack.length - 1]) { + stack.pop() + } + else if (!stack.length && s[i] === ',') { + const token = s.substring(start, i).trim() + if (token) + result.push(token) + start = i + 1 + } + } + const lastToken = s.substring(start).trim() + if (lastToken) + result.push(lastToken) + return result } diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 0fc271f726f6..55ad3df09603 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -4,7 +4,8 @@ import type { VitestRunner } from './types/runner' import { createChainable } from './utils/chain' import { collectTask, collectorContext, createTestContext, runWithSuite, withTimeout } from './context' import { getHooks, setFn, setHooks } from './map' -import { withFixtures } from './fixture' +import type { FixtureItem } from './fixture' +import { mergeContextFixtures, withFixtures } from './fixture' // apis export const suite = createSuite() @@ -232,7 +233,7 @@ function createSuite() { function createTest(fn: ( ( - this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: Fixtures> }, + this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: FixtureItem[] }, title: string, fn?: TestFunction, options?: number | TestOptions @@ -266,20 +267,22 @@ function createTest(fn: ( testFn.runIf = (condition: any) => (condition ? test : test.skip) as TestAPI testFn.extend = function (fixtures: Fixtures>) { - const _context = context - ? { ...context, fixtures: { ...context.fixtures, ...fixtures } } - : { fixtures } + const _context = mergeContextFixtures(fixtures, context) return createTest(function fn(name: string | Function, fn?: TestFunction, options?: number | TestOptions) { getCurrentSuite().test.fn.call(this, formatName(name), fn, options) }, _context) } - return createChainable( + const _test = createChainable( ['concurrent', 'skip', 'only', 'todo', 'fails'], testFn, - context, ) as TestAPI + + if (context) + (_test as any).mergeContext(context) + + return _test } function formatName(name: string | Function) { diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index b1e41e46d08b..07c3c4e0da33 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -182,11 +182,20 @@ export type TestAPI = ChainableTestAPI & { each: TestEachFunction skipIf(condition: any): ChainableTestAPI runIf(condition: any): ChainableTestAPI - extend>(fixtures: Fixtures): TestAPI + extend = {}>(fixtures: Fixtures): TestAPI<{ + [K in keyof T | keyof ExtraContext]: + K extends keyof T ? T[K] : + K extends keyof ExtraContext ? ExtraContext[K] : never }> } -export type Fixtures> = { - [K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise) => Promise) +export type Fixtures, ExtraContext = {}> = { + [K in keyof T]: T[K] | ((context: { + [P in keyof T | keyof ExtraContext as P extends K ? + P extends keyof ExtraContext ? P : never : P + ]: + K extends P ? K extends keyof ExtraContext ? ExtraContext[K] : never : + P extends keyof T ? T[P] : never + } & TestContext, use: (fixture: T[K]) => Promise) => Promise) } type ChainableSuiteAPI = ChainableFunction< diff --git a/packages/runner/src/utils/chain.ts b/packages/runner/src/utils/chain.ts index 9b55c0ee58e7..ef1c3c6cc029 100644 --- a/packages/runner/src/utils/chain.ts +++ b/packages/runner/src/utils/chain.ts @@ -9,7 +9,6 @@ export type ChainableFunction( keys: T[], fn: (this: Record, ...args: Args) => R, - initialContext?: Record, ): ChainableFunction { function create(context: Record) { const chain = function (this: any, ...args: Args) { @@ -20,6 +19,9 @@ export function createChainable { context[key] = value } + chain.mergeContext = (ctx: Record) => { + Object.assign(context, ctx) + } for (const key of keys) { Object.defineProperty(chain, key, { get() { @@ -30,7 +32,7 @@ export function createChainable>({ + a: 1, + b: async ({ a }, use) => { + fnB() + await use (a * 2) // 2 + fnB.mockClear() + }, +}) + +const fnA = vi.fn() +const fnB2 = vi.fn() +const fnC = vi.fn() +const fnD = vi.fn() +const myTest2 = myTest.extend & { a: string; b: string }>({ + // override origin a + a: async ({ a: originA }, use) => { + expectTypeOf(originA).toEqualTypeOf() + fnA() + await use(String(originA)) // '1' + fnA.mockClear() + }, + b: async ({ a }, use) => { + expectTypeOf(a).toEqualTypeOf() + fnB2() + await use(String(Number(a) * 2)) // '2' + fnB2.mockClear() + }, + c: async ({ a, b }, use) => { + expectTypeOf(b).toEqualTypeOf() + fnC() + await use(Number(a) + Number(b)) // 3 + fnC.mockClear() + }, + d: async ({ a, b, c }, use) => { + fnD() + await use(Number(a) + Number(b) + c) // 6 + fnD.mockClear() + }, +}) + +describe('fixture initialization', () => { + describe('fixture override', () => { + myTest('origin a and b', ({ a, b }) => { + expect(a).toBe(1) + expect(b).toBe(2) + + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + + expect(fnB).toBeCalledTimes(1) + + expect(fnB2).not.toBeCalled() + expect(fnA).not.toBeCalled() + expect(fnC).not.toBeCalled() + expect(fnD).not.toBeCalled() + }) + + myTest2('overriding a and b', ({ a, b }) => { + expect(a).toBe('1') + expect(b).toBe('2') + + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + + expect(fnC).not.toBeCalled() + expect(fnD).not.toBeCalled() + }) + }) + + describe('fixture dependency', () => { + myTest2('b => a', ({ b }) => { + expect(b).toBe('2') + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + + expect(fnC).not.toBeCalled() + expect(fnD).not.toBeCalled() + }) + + myTest2('c => [a, b]', ({ c }) => { + expect(c).toBe(3) + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + expect(fnC).toBeCalledTimes(1) + + expect(fnD).not.toBeCalled() + }) + + myTest2('d => c', ({ d }) => { + expect(d).toBe(6) + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + expect(fnC).toBeCalledTimes(1) + expect(fnD).toBeCalledTimes(1) + }) + + myTest2('should only call once for each fixture fn', ({ a, b, c, d }) => { + expect(a).toBe('1') + expect(b).toBe('2') + expect(c).toBe(3) + expect(d).toBe(6) + + expect(fnA).toBeCalledTimes(1) + expect(fnB).toBeCalledTimes(1) + expect(fnB2).toBeCalledTimes(1) + expect(fnC).toBeCalledTimes(1) + expect(fnD).toBeCalledTimes(1) + }) + }) +}) diff --git a/test/core/test/test-extend.test.ts b/test/core/test/test-extend.test.ts index 1d75bd3ae15f..c03fbe0bee78 100644 --- a/test/core/test/test-extend.test.ts +++ b/test/core/test/test-extend.test.ts @@ -1,32 +1,39 @@ -/* eslint-disable no-empty-pattern */ /* eslint-disable prefer-rest-params */ +/* eslint-disable no-empty-pattern */ import { describe, expect, expectTypeOf, test, vi } from 'vitest' +interface Fixtures { + todoList: number[] + doneList: number[] + archiveList: number[] +} + const todoList: number[] = [1, 2, 3] const doneList: number[] = [] const archiveList: number[] = [] -const todoFn = vi.fn().mockImplementation(async (use) => { - await use(todoList) - // cleanup - todoFn.mockClear() - todoList.length = 0 - todoList.push(1, 2, 3) -}) - -const doneFn = vi.fn().mockImplementation(async (use) => { - await use(doneList) - // cleanup - doneFn.mockClear() - doneList.length = 0 -}) +const todoFn = vi.fn() +const doneFn = vi.fn() const myTest = test - .extend<{ todoList: number[] }>({ - todoList: todoFn, + .extend>({ + todoList: async ({}, use) => { + todoFn() + await use(todoList) + // cleanup + todoFn.mockClear() + todoList.length = 0 + todoList.push(1, 2, 3) + }, }) - .extend<{ doneList: number[]; archiveList: number[] }>({ - doneList: doneFn, + .extend>({ + doneList: async ({}, use) => { + doneFn() + await use(doneList) + // cleanup + doneFn.mockClear() + doneList.length = 0 + }, archiveList, }) @@ -54,12 +61,14 @@ describe('test.extend()', () => { archiveList.push(todoList.shift()!) expect(todoList).toEqual([]) expect(archiveList).toEqual([3]) + + archiveList.pop() }) myTest('should called cleanup functions', ({ todoList, doneList, archiveList }) => { expect(todoList).toEqual([1, 2, 3]) expect(doneList).toEqual([]) - expect(archiveList).toEqual([3]) + expect(archiveList).toEqual([]) }) describe('smartly init fixtures', () => { @@ -114,30 +123,31 @@ describe('test.extend()', () => { expect(arguments[0].archiveList).toBeUndefined() }) - myTest('should init all fixtures', ({ todoList, ...rest }) => { - expect(todoFn).toBeCalledTimes(1) + myTest('should only init doneList and archiveList', function ({ doneList, archiveList }) { expect(doneFn).toBeCalledTimes(1) - expectTypeOf(todoList).toEqualTypeOf() - expectTypeOf(rest.doneList).toEqualTypeOf() - expectTypeOf(rest.archiveList).toEqualTypeOf() + expectTypeOf(doneList).toEqualTypeOf() + expectTypeOf(archiveList).toEqualTypeOf() + expectTypeOf(arguments[0].todoList).not.toEqualTypeOf() - expect(todoList).toEqual([1, 2, 3]) - expect(rest.doneList).toEqual([]) - expect(rest.archiveList).toEqual([3]) + expect(doneList).toEqual([]) + expect(archiveList).toEqual([]) + expect(arguments[0].todoList).toBeUndefined() }) + }) - myTest('should init all fixtures', (context) => { + describe('test function', () => { + myTest('prop alias', ({ todoList: todos, doneList: done, archiveList: archive }) => { expect(todoFn).toBeCalledTimes(1) expect(doneFn).toBeCalledTimes(1) - expectTypeOf(context.todoList).toEqualTypeOf() - expectTypeOf(context.doneList).toEqualTypeOf() - expectTypeOf(context.archiveList).toEqualTypeOf() + expectTypeOf(todos).toEqualTypeOf() + expectTypeOf(done).toEqualTypeOf() + expectTypeOf(archive).toEqualTypeOf() - expect(context.todoList).toEqual([1, 2, 3]) - expect(context.doneList).toEqual([]) - expect(context.archiveList).toEqual([3]) + expect(todos).toEqual([1, 2, 3]) + expect(done).toEqual([]) + expect(archive).toEqual([]) }) }) }) diff --git a/test/fails/fixtures/test-extend/circular-dependency.test.ts b/test/fails/fixtures/test-extend/circular-dependency.test.ts new file mode 100644 index 000000000000..3e69e296d640 --- /dev/null +++ b/test/fails/fixtures/test-extend/circular-dependency.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest' + +const myTest = test.extend<{ a: number; b: number }>({ + a: async ({ b }, use) => { + await use(b) + }, + b: async ({ a }, use) => { + await use(a) + }, +}) + +myTest('', ({ a }) => { + expect(a).toBe(0) +}) diff --git a/test/fails/fixtures/test-extend/fixture-rest-params.test.ts b/test/fails/fixtures/test-extend/fixture-rest-params.test.ts new file mode 100644 index 000000000000..262727e828d5 --- /dev/null +++ b/test/fails/fixtures/test-extend/fixture-rest-params.test.ts @@ -0,0 +1,6 @@ +import { test } from 'vitest' + +test.extend({ + // eslint-disable-next-line unused-imports/no-unused-vars + a: async (...rest) => {}, +}) diff --git a/test/fails/fixtures/test-extend/fixture-rest-props.test.ts b/test/fails/fixtures/test-extend/fixture-rest-props.test.ts new file mode 100644 index 000000000000..ce8127338dda --- /dev/null +++ b/test/fails/fixtures/test-extend/fixture-rest-props.test.ts @@ -0,0 +1,6 @@ +import { test } from 'vitest' + +test.extend({ + // eslint-disable-next-line unused-imports/no-unused-vars + a: async ({ ...rest }) => {}, +}) diff --git a/test/fails/fixtures/test-extend/fixture-without-destructuring.test.ts b/test/fails/fixtures/test-extend/fixture-without-destructuring.test.ts new file mode 100644 index 000000000000..4cc6881645fe --- /dev/null +++ b/test/fails/fixtures/test-extend/fixture-without-destructuring.test.ts @@ -0,0 +1,6 @@ +import { test } from 'vitest' + +test.extend({ + // eslint-disable-next-line unused-imports/no-unused-vars + a: async (context) => {}, +}) diff --git a/test/fails/fixtures/test-extend/test-rest-params.test.ts b/test/fails/fixtures/test-extend/test-rest-params.test.ts new file mode 100644 index 000000000000..b058a3a05b59 --- /dev/null +++ b/test/fails/fixtures/test-extend/test-rest-params.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +const myTest = test.extend({ a: 1 }) + +// eslint-disable-next-line unused-imports/no-unused-vars +myTest('', (...rest) => { + +}) diff --git a/test/fails/fixtures/test-extend/test-rest-props.test.ts b/test/fails/fixtures/test-extend/test-rest-props.test.ts new file mode 100644 index 000000000000..31492881ed74 --- /dev/null +++ b/test/fails/fixtures/test-extend/test-rest-props.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +const myTest = test.extend({ a: 1 }) + +// eslint-disable-next-line unused-imports/no-unused-vars +myTest('', ({ ...rest }) => { + +}) diff --git a/test/fails/fixtures/test-extend/test-without-destructuring.test.ts b/test/fails/fixtures/test-extend/test-without-destructuring.test.ts new file mode 100644 index 000000000000..24d9aa774d9c --- /dev/null +++ b/test/fails/fixtures/test-extend/test-without-destructuring.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +const myTest = test.extend({ a: 1 }) + +// eslint-disable-next-line unused-imports/no-unused-vars +myTest('', (context) => { + +}) diff --git a/test/fails/test/__snapshots__/runner.test.ts.snap b/test/fails/test/__snapshots__/runner.test.ts.snap index 68475f764dcb..3e31825c19a2 100644 --- a/test/fails/test/__snapshots__/runner.test.ts.snap +++ b/test/fails/test/__snapshots__/runner.test.ts.snap @@ -40,6 +40,20 @@ TypeError: failure TypeError: failure" `; +exports[`should fail test-extend/circular-dependency.test.ts > test-extend/circular-dependency.test.ts 1`] = `"Error: circular fixture dependency"`; + +exports[`should fail test-extend/fixture-rest-params.test.ts > test-extend/fixture-rest-params.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + +exports[`should fail test-extend/fixture-rest-props.test.ts > test-extend/fixture-rest-props.test.ts 1`] = `"Error: Rest parameters are not supported"`; + +exports[`should fail test-extend/fixture-without-destructuring.test.ts > test-extend/fixture-without-destructuring.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + +exports[`should fail test-extend/test-rest-params.test.ts > test-extend/test-rest-params.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + +exports[`should fail test-extend/test-rest-props.test.ts > test-extend/test-rest-props.test.ts 1`] = `"Error: Rest parameters are not supported"`; + +exports[`should fail test-extend/test-without-destructuring.test.ts > test-extend/test-without-destructuring.test.ts 1`] = `"Error: the first argument must use object destructuring pattern"`; + exports[`should fail test-timeout.test.ts > test-timeout.test.ts 1`] = ` "Error: Test timed out in 200ms. Error: Test timed out in 100ms.