Skip to content

Commit 1621cc6

Browse files
authored
feat: support accessing other fixtures in fixture function (#3651)
1 parent d77f712 commit 1621cc6

16 files changed

+395
-94
lines changed

docs/api/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ In Jest, `TestFunction` can also be of type `(done: DoneCallback) => void`. If t
7070
const archive = []
7171

7272
const myTest = test.extend({
73-
todos: async (use) => {
73+
todos: async ({ task }, use) => {
7474
todos.push(1, 2, 3)
7575
await use(todos)
7676
todos.length = 0

docs/guide/test-context.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const todos = []
5151
const archive = []
5252

5353
export const myTest = test.extend({
54-
todos: async (use) => {
54+
todos: async ({ task }, use) => {
5555
// setup the fixture before each test function
5656
todos.push(1, 2, 3)
5757

@@ -105,7 +105,7 @@ Vitest runner will smartly initialize your fixtures and inject them into the tes
105105
```ts
106106
import { test } from 'vitest'
107107

108-
async function todosFn(use) {
108+
async function todosFn({ task }, use) {
109109
await use([1, 2, 3])
110110
}
111111

@@ -115,15 +115,17 @@ const myTest = test.extend({
115115
})
116116

117117
// todosFn will not run
118-
myTest('', () => {}) // no fixture is available
119-
myTets('', ({ archive }) => {}) // only archive is available
118+
myTest('', () => {})
119+
myTets('', ({ archive }) => {})
120120

121121
// todosFn will run
122-
myTest('', ({ todos }) => {}) // only todos is available
123-
myTest('', (context) => {}) // both are available
124-
myTest('', ({ archive, ...rest }) => {}) // both are available
122+
myTest('', ({ todos }) => {})
125123
```
126124

125+
::: warning
126+
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.
127+
:::
128+
127129
#### TypeScript
128130

129131
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: 114 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,141 @@
1-
import type { Fixtures, Test } from './types'
1+
import type { TestContext } from './types'
2+
3+
export interface FixtureItem {
4+
prop: string
5+
value: any
6+
index: number
7+
/**
8+
* Indicates whether the fixture is a function
9+
*/
10+
isFn: boolean
11+
/**
12+
* The dependencies(fixtures) of current fixture function.
13+
*/
14+
deps?: FixtureItem[]
15+
}
16+
17+
export function mergeContextFixtures(fixtures: Record<string, any>, context: { fixtures?: FixtureItem[] } = {}) {
18+
const fixtureArray: FixtureItem[] = Object.entries(fixtures)
19+
.map(([prop, value], index) => {
20+
const isFn = typeof value === 'function'
21+
return {
22+
prop,
23+
value,
24+
index,
25+
isFn,
26+
}
27+
})
28+
29+
if (Array.isArray(context.fixtures))
30+
context.fixtures = context.fixtures.concat(fixtureArray)
31+
else
32+
context.fixtures = fixtureArray
33+
34+
// Update dependencies of fixture functions
35+
fixtureArray.forEach((fixture) => {
36+
if (fixture.isFn) {
37+
const usedProps = getUsedProps(fixture.value)
38+
if (usedProps.length)
39+
fixture.deps = context.fixtures!.filter(({ index, prop }) => index !== fixture.index && usedProps.includes(prop))
40+
}
41+
})
242

3-
export function withFixtures(fn: Function, fixtures: Fixtures<Record<string, any>>, context: Test<Record<string, any>>['context']) {
4-
const props = getUsedFixtureProps(fn, Object.keys(fixtures))
43+
return context
44+
}
545

6-
if (props.length === 0)
46+
export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record<string, any>) {
47+
if (!fixtures.length)
748
return () => fn(context)
849

50+
const usedProps = getUsedProps(fn)
51+
if (!usedProps.length)
52+
return () => fn(context)
53+
54+
const usedFixtures = fixtures.filter(({ prop }) => usedProps.includes(prop))
55+
const pendingFixtures = resolveDeps(usedFixtures)
956
let cursor = 0
1057

1158
async function use(fixtureValue: any) {
12-
context[props[cursor++]] = fixtureValue
13-
14-
if (cursor < props.length)
59+
const { prop } = pendingFixtures[cursor++]
60+
context[prop] = fixtureValue
61+
if (cursor < pendingFixtures.length)
1562
await next()
1663
else await fn(context)
1764
}
1865

1966
async function next() {
20-
const fixtureValue = fixtures[props[cursor]]
21-
typeof fixtureValue === 'function'
22-
? await fixtureValue(use)
23-
: await use(fixtureValue)
67+
const { value } = pendingFixtures[cursor]
68+
typeof value === 'function' ? await value(context, use) : await use(value)
2469
}
2570

2671
return () => next()
2772
}
2873

29-
function getUsedFixtureProps(fn: Function, fixtureProps: string[]) {
30-
if (!fixtureProps.length || !fn.length)
31-
return []
74+
function resolveDeps(fixtures: FixtureItem[], depSet = new Set<FixtureItem>(), pendingFixtures: FixtureItem[] = []) {
75+
fixtures.forEach((fixture) => {
76+
if (pendingFixtures.includes(fixture))
77+
return
78+
if (!fixture.isFn || !fixture.deps) {
79+
pendingFixtures.push(fixture)
80+
return
81+
}
82+
if (depSet.has(fixture))
83+
throw new Error('circular fixture dependency')
3284

33-
const paramsStr = fn.toString().match(/[^(]*\(([^)]*)/)![1]
85+
depSet.add(fixture)
86+
resolveDeps(fixture.deps, depSet, pendingFixtures)
87+
pendingFixtures.push(fixture)
88+
depSet.clear()
89+
})
3490

35-
if (paramsStr[0] === '{' && paramsStr.at(-1) === '}') {
36-
// ({...}) => {}
37-
const props = paramsStr.slice(1, -1).split(',')
38-
const filteredProps = []
91+
return pendingFixtures
92+
}
3993

40-
for (const prop of props) {
41-
if (!prop)
42-
continue
94+
function getUsedProps(fn: Function) {
95+
const match = fn.toString().match(/[^(]*\(([^)]*)/)
96+
if (!match)
97+
return []
4398

44-
let _prop = prop.trim()
99+
const args = splitByComma(match[1])
100+
if (!args.length)
101+
return []
45102

46-
if (_prop.startsWith('...')) {
47-
// ({ a, b, ...rest }) => {}
48-
return fixtureProps
49-
}
103+
const first = args[0]
104+
if (!(first.startsWith('{') && first.endsWith('}')))
105+
throw new Error('the first argument must use object destructuring pattern')
50106

51-
const colonIndex = _prop.indexOf(':')
52-
if (colonIndex > 0)
53-
_prop = _prop.slice(0, colonIndex).trim()
107+
const _first = first.slice(1, -1).replace(/\s/g, '')
108+
const props = splitByComma(_first).map((prop) => {
109+
return prop.replace(/\:.*|\=.*/g, '')
110+
})
54111

55-
if (fixtureProps.includes(_prop))
56-
filteredProps.push(_prop)
57-
}
112+
const last = props.at(-1)
113+
if (last && last.startsWith('...'))
114+
throw new Error('Rest parameters are not supported')
58115

59-
// ({}) => {}
60-
// ({ a, b, c}) => {}
61-
return filteredProps
62-
}
116+
return props
117+
}
63118

64-
// (ctx) => {}
65-
return fixtureProps
119+
function splitByComma(s: string) {
120+
const result = []
121+
const stack = []
122+
let start = 0
123+
for (let i = 0; i < s.length; i++) {
124+
if (s[i] === '{' || s[i] === '[') {
125+
stack.push(s[i] === '{' ? '}' : ']')
126+
}
127+
else if (s[i] === stack[stack.length - 1]) {
128+
stack.pop()
129+
}
130+
else if (!stack.length && s[i] === ',') {
131+
const token = s.substring(start, i).trim()
132+
if (token)
133+
result.push(token)
134+
start = i + 1
135+
}
136+
}
137+
const lastToken = s.substring(start).trim()
138+
if (lastToken)
139+
result.push(lastToken)
140+
return result
66141
}

packages/runner/src/suite.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type { VitestRunner } from './types/runner'
44
import { createChainable } from './utils/chain'
55
import { collectTask, collectorContext, createTestContext, runWithSuite, withTimeout } from './context'
66
import { getHooks, setFn, setHooks } from './map'
7-
import { withFixtures } from './fixture'
7+
import type { FixtureItem } from './fixture'
8+
import { mergeContextFixtures, withFixtures } from './fixture'
89

910
// apis
1011
export const suite = createSuite()
@@ -232,7 +233,7 @@ function createSuite() {
232233

233234
function createTest(fn: (
234235
(
235-
this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: Fixtures<Record<string, any>> },
236+
this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: FixtureItem[] },
236237
title: string,
237238
fn?: TestFunction,
238239
options?: number | TestOptions
@@ -266,20 +267,22 @@ function createTest(fn: (
266267
testFn.runIf = (condition: any) => (condition ? test : test.skip) as TestAPI
267268

268269
testFn.extend = function (fixtures: Fixtures<Record<string, any>>) {
269-
const _context = context
270-
? { ...context, fixtures: { ...context.fixtures, ...fixtures } }
271-
: { fixtures }
270+
const _context = mergeContextFixtures(fixtures, context)
272271

273272
return createTest(function fn(name: string | Function, fn?: TestFunction, options?: number | TestOptions) {
274273
getCurrentSuite().test.fn.call(this, formatName(name), fn, options)
275274
}, _context)
276275
}
277276

278-
return createChainable(
277+
const _test = createChainable(
279278
['concurrent', 'skip', 'only', 'todo', 'fails'],
280279
testFn,
281-
context,
282280
) as TestAPI
281+
282+
if (context)
283+
(_test as any).mergeContext(context)
284+
285+
return _test
283286
}
284287

285288
function formatName(name: string | Function) {

packages/runner/src/types/tasks.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,20 @@ export type TestAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> & {
182182
each: TestEachFunction
183183
skipIf(condition: any): ChainableTestAPI<ExtraContext>
184184
runIf(condition: any): ChainableTestAPI<ExtraContext>
185-
extend<T extends Record<string, any>>(fixtures: Fixtures<T>): TestAPI<ExtraContext & T>
185+
extend<T extends Record<string, any> = {}>(fixtures: Fixtures<T, ExtraContext>): TestAPI<{
186+
[K in keyof T | keyof ExtraContext]:
187+
K extends keyof T ? T[K] :
188+
K extends keyof ExtraContext ? ExtraContext[K] : never }>
186189
}
187190

188-
export type Fixtures<T extends Record<string, any>> = {
189-
[K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise<void>) => Promise<void>)
191+
export type Fixtures<T extends Record<string, any>, ExtraContext = {}> = {
192+
[K in keyof T]: T[K] | ((context: {
193+
[P in keyof T | keyof ExtraContext as P extends K ?
194+
P extends keyof ExtraContext ? P : never : P
195+
]:
196+
K extends P ? K extends keyof ExtraContext ? ExtraContext[K] : never :
197+
P extends keyof T ? T[P] : never
198+
} & TestContext, use: (fixture: T[K]) => Promise<void>) => Promise<void>)
190199
}
191200

192201
type ChainableSuiteAPI<ExtraContext = {}> = ChainableFunction<

packages/runner/src/utils/chain.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export type ChainableFunction<T extends string, Args extends any[], R = any, E =
99
export function createChainable<T extends string, Args extends any[], R = any, E = {}>(
1010
keys: T[],
1111
fn: (this: Record<T, any>, ...args: Args) => R,
12-
initialContext?: Record<T, any>,
1312
): ChainableFunction<T, Args, R, E> {
1413
function create(context: Record<T, any>) {
1514
const chain = function (this: any, ...args: Args) {
@@ -20,6 +19,9 @@ export function createChainable<T extends string, Args extends any[], R = any, E
2019
chain.setContext = (key: T, value: any) => {
2120
context[key] = value
2221
}
22+
chain.mergeContext = (ctx: Record<T, any>) => {
23+
Object.assign(context, ctx)
24+
}
2325
for (const key of keys) {
2426
Object.defineProperty(chain, key, {
2527
get() {
@@ -30,7 +32,7 @@ export function createChainable<T extends string, Args extends any[], R = any, E
3032
return chain
3133
}
3234

33-
const chain = create(initialContext || {} as any) as any
35+
const chain = create({} as any) as any
3436
chain.fn = fn
3537
return chain
3638
}

0 commit comments

Comments
 (0)