Skip to content

Commit 2db1a73

Browse files
authored
feat(runner): support test.extend (#3554)
1 parent 7531c29 commit 2db1a73

File tree

7 files changed

+382
-11
lines changed

7 files changed

+382
-11
lines changed

docs/api/index.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,36 @@ In Jest, `TestFunction` can also be of type `(done: DoneCallback) => void`. If t
5151
})
5252
```
5353

54+
### test.extend
55+
56+
- **Type:** `<T extends Record<string, any>>(fixtures: Fixtures<T>): TestAPI<ExtraContext & T>`
57+
- **Alias:** `it.extend`
58+
59+
Use `test.extend` to extend the test context with custom fixtures. This will return a new `test` and it's also extendable, so you can compose more fixtures or override existing ones by extending it as you need. See [Extend Test Context](/guide/test-context.html#test-extend) for more information.
60+
61+
```ts
62+
import { expect, test } from 'vitest'
63+
64+
const todos = []
65+
const archive = []
66+
67+
const myTest = test.extend({
68+
todos: async (use) => {
69+
todos.push(1, 2, 3)
70+
await use(todos)
71+
todos.length = 0
72+
},
73+
archive
74+
})
75+
76+
myTest('add item', ({ todos }) => {
77+
expect(todos.length).toBe(3)
78+
79+
todos.push(4)
80+
expect(todos.length).toBe(4)
81+
})
82+
```
83+
5484
### test.skip
5585

5686
- **Type:** `(name: string | Function, fn: TestFunction, timeout?: number | TestOptions) => void`

docs/guide/test-context.md

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,118 @@ The `expect` API bound to the current test.
3131

3232
## Extend Test Context
3333

34+
Vitest provides two diffident ways to help you extend the test context.
35+
36+
### `test.extend`
37+
38+
Like [Playwright](https://playwright.dev/docs/api/class-test#test-extend), you can use this method to define your own `test` API with custom fixtures and reuse it anywhere.
39+
40+
For example, we first create `myTest` with two fixtures, `todos` and `archive`.
41+
42+
```ts
43+
// my-test.ts
44+
import { test } from 'vitest'
45+
46+
const todos = []
47+
const archive = []
48+
49+
export const myTest = test.extend({
50+
todos: async (use) => {
51+
// setup the fixture before each test function
52+
todos.push(1, 2, 3)
53+
54+
// use the fixture value
55+
await use(todos)
56+
57+
// cleanup the fixture after each test function
58+
todos.length = 0
59+
},
60+
archive
61+
})
62+
```
63+
64+
Then we can import and use it.
65+
66+
```ts
67+
import { expect } from 'vitest'
68+
import { myTest } from './my-test.ts'
69+
70+
myTest('add items to todos', ({ todos }) => {
71+
expect(todos.length).toBe(3)
72+
73+
todos.add(4)
74+
expect(todos.length).toBe(4)
75+
})
76+
77+
myTest('move items from todos to archive', ({ todos, archive }) => {
78+
expect(todos.length).toBe(3)
79+
expect(archive.length).toBe(0)
80+
81+
archive.push(todos.pop())
82+
expect(todos.length).toBe(2)
83+
expect(archive.length).toBe(1)
84+
})
85+
```
86+
87+
We can also add more fixtures or override existing fixtures by extending `myTest`.
88+
89+
```ts
90+
export const myTest2 = myTest.extend({
91+
settings: {
92+
// ...
93+
}
94+
})
95+
```
96+
97+
#### Fixture initialization
98+
99+
Vitest runner will smartly initialize your fixtures and inject them into the test context based on usage.
100+
101+
```ts
102+
import { test } from 'vitest'
103+
104+
async function todosFn(use) {
105+
await use([1, 2, 3])
106+
}
107+
108+
const myTest = test.extend({
109+
todos: todosFn,
110+
archive: []
111+
})
112+
113+
// todosFn will not run
114+
myTest('', () => {}) // no fixture is available
115+
myTets('', ({ archive }) => {}) // only archive is available
116+
117+
// todosFn will run
118+
myTest('', ({ todos }) => {}) // only todos is available
119+
myTest('', (context) => {}) // both are available
120+
myTest('', ({ archive, ...rest }) => {}) // both are available
121+
```
122+
123+
#### TypeScript
124+
125+
To provide fixture types for all your custom contexts, you can pass the fixtures type as a generic.
126+
127+
```ts
128+
interface MyFixtures {
129+
todos: number[]
130+
archive: number[]
131+
}
132+
133+
const myTest = test.extend<MyFixtures>({
134+
todos: [],
135+
archive: []
136+
})
137+
138+
myTest('', (context) => {
139+
expectTypeOf(context.todos).toEqualTypeOf<number[]>()
140+
expectTypeOf(context.archive).toEqualTypeOf<number[]>()
141+
})
142+
```
143+
144+
### `beforeEach` and `afterEach`
145+
34146
The contexts are different for each test. You can access and extend them within the `beforeEach` and `afterEach` hooks.
35147

36148
```ts
@@ -46,7 +158,7 @@ it('should work', ({ foo }) => {
46158
})
47159
```
48160

49-
### TypeScript
161+
#### TypeScript
50162

51163
To provide property types for all your custom contexts, you can aggregate the `TestContext` type by adding
52164

@@ -74,4 +186,4 @@ it<LocalTestContext>('should work', ({ foo }) => {
74186
// typeof foo is 'string'
75187
console.log(foo) // 'bar'
76188
})
77-
```
189+
```

packages/runner/src/fixture.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Fixtures, Test } from './types'
2+
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))
5+
6+
if (props.length === 0)
7+
return () => fn(context)
8+
9+
let cursor = 0
10+
11+
async function use(fixtureValue: any) {
12+
context[props[cursor++]] = fixtureValue
13+
14+
if (cursor < props.length)
15+
await next()
16+
else await fn(context)
17+
}
18+
19+
async function next() {
20+
const fixtureValue = fixtures[props[cursor]]
21+
typeof fixtureValue === 'function'
22+
? await fixtureValue(use)
23+
: await use(fixtureValue)
24+
}
25+
26+
return () => next()
27+
}
28+
29+
function getUsedFixtureProps(fn: Function, fixtureProps: string[]) {
30+
if (!fixtureProps.length || !fn.length)
31+
return []
32+
33+
const paramsStr = fn.toString().match(/[^(]*\(([^)]*)/)![1]
34+
35+
if (paramsStr[0] === '{' && paramsStr.at(-1) === '}') {
36+
// ({...}) => {}
37+
const props = paramsStr.slice(1, -1).split(',')
38+
const filteredProps = []
39+
40+
for (const prop of props) {
41+
if (!prop)
42+
continue
43+
44+
let _prop = prop.trim()
45+
46+
if (_prop.startsWith('...')) {
47+
// ({ a, b, ...rest }) => {}
48+
return fixtureProps
49+
}
50+
51+
const colonIndex = _prop.indexOf(':')
52+
if (colonIndex > 0)
53+
_prop = _prop.slice(0, colonIndex).trim()
54+
55+
if (fixtureProps.includes(_prop))
56+
filteredProps.push(_prop)
57+
}
58+
59+
// ({}) => {}
60+
// ({ a, b, c}) => {}
61+
return filteredProps
62+
}
63+
64+
// (ctx) => {}
65+
return fixtureProps
66+
}

packages/runner/src/suite.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { format, isObject, noop, objDisplay, objectAttr } from '@vitest/utils'
2-
import type { File, RunMode, Suite, SuiteAPI, SuiteCollector, SuiteFactory, SuiteHooks, Task, TaskCustom, Test, TestAPI, TestFunction, TestOptions } from './types'
2+
import type { File, Fixtures, RunMode, Suite, SuiteAPI, SuiteCollector, SuiteFactory, SuiteHooks, Task, TaskCustom, Test, TestAPI, TestFunction, TestOptions } from './types'
33
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'
78

89
// apis
910
export const suite = createSuite()
@@ -95,7 +96,9 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
9596
})
9697

9798
setFn(test, withTimeout(
98-
() => fn(context),
99+
this.fixtures
100+
? withFixtures(fn, this.fixtures, context)
101+
: () => fn(context),
99102
options?.timeout ?? runner.config.testTimeout,
100103
))
101104

@@ -229,12 +232,12 @@ function createSuite() {
229232

230233
function createTest(fn: (
231234
(
232-
this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined>,
235+
this: Record<'concurrent' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: Fixtures<Record<string, any>> },
233236
title: string,
234237
fn?: TestFunction,
235238
options?: number | TestOptions
236239
) => void
237-
)) {
240+
), context?: Record<string, any>) {
238241
const testFn = fn as any
239242

240243
testFn.each = function<T>(this: { withContext: () => SuiteAPI; setContext: (key: string, value: boolean | undefined) => SuiteAPI }, cases: ReadonlyArray<T>, ...args: any[]) {
@@ -262,9 +265,20 @@ function createTest(fn: (
262265
testFn.skipIf = (condition: any) => (condition ? test.skip : test) as TestAPI
263266
testFn.runIf = (condition: any) => (condition ? test : test.skip) as TestAPI
264267

268+
testFn.extend = function (fixtures: Fixtures<Record<string, any>>) {
269+
const _context = context
270+
? { ...context, fixtures: { ...context.fixtures, ...fixtures } }
271+
: { fixtures }
272+
273+
return createTest(function fn(name: string | Function, fn?: TestFunction, options?: number | TestOptions) {
274+
getCurrentSuite().test.fn.call(this, formatName(name), fn, options)
275+
}, _context)
276+
}
277+
265278
return createChainable(
266279
['concurrent', 'skip', 'only', 'todo', 'fails'],
267280
testFn,
281+
context,
268282
) as TestAPI
269283
}
270284

packages/runner/src/types/tasks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ export type TestAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> & {
183183
each: TestEachFunction
184184
skipIf(condition: any): ChainableTestAPI<ExtraContext>
185185
runIf(condition: any): ChainableTestAPI<ExtraContext>
186+
extend<T extends Record<string, any>>(fixtures: Fixtures<T>): TestAPI<ExtraContext & T>
187+
}
188+
189+
export type Fixtures<T extends Record<string, any>> = {
190+
[K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise<void>) => Promise<void>)
186191
}
187192

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

packages/runner/src/utils/chain.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,21 @@ export type ChainableFunction<T extends string, Args extends any[], R = any, E =
33
} & {
44
[x in T]: ChainableFunction<T, Args, R, E>
55
} & {
6-
fn: (this: Record<T, boolean | undefined>, ...args: Args) => R
6+
fn: (this: Record<T, any>, ...args: Args) => R
77
} & E
88

99
export function createChainable<T extends string, Args extends any[], R = any, E = {}>(
1010
keys: T[],
11-
fn: (this: Record<T, boolean | undefined>, ...args: Args) => R,
11+
fn: (this: Record<T, any>, ...args: Args) => R,
12+
initialContext?: Record<T, any>,
1213
): ChainableFunction<T, Args, R, E> {
13-
function create(context: Record<T, boolean | undefined>) {
14+
function create(context: Record<T, any>) {
1415
const chain = function (this: any, ...args: Args) {
1516
return fn.apply(context, args)
1617
}
1718
Object.assign(chain, fn)
1819
chain.withContext = () => chain.bind(context)
19-
chain.setContext = (key: T, value: boolean | undefined) => {
20+
chain.setContext = (key: T, value: any) => {
2021
context[key] = value
2122
}
2223
for (const key of keys) {
@@ -29,7 +30,7 @@ export function createChainable<T extends string, Args extends any[], R = any, E
2930
return chain
3031
}
3132

32-
const chain = create({} as any) as any
33+
const chain = create(initialContext || {} as any) as any
3334
chain.fn = fn
3435
return chain
3536
}

0 commit comments

Comments
 (0)