Skip to content

Commit 2db1a73

Browse files
authoredJun 19, 2023
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

+30
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

+114-2
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

+66
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

+18-4
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

+5
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

+6-5
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
}

‎test/core/test/test-extend.test.ts

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/* eslint-disable no-empty-pattern */
2+
/* eslint-disable prefer-rest-params */
3+
import { describe, expect, expectTypeOf, test, vi } from 'vitest'
4+
5+
const todoList: number[] = [1, 2, 3]
6+
const doneList: number[] = []
7+
const archiveList: number[] = []
8+
9+
const todoFn = vi.fn().mockImplementation(async (use) => {
10+
await use(todoList)
11+
// cleanup
12+
todoFn.mockClear()
13+
todoList.length = 0
14+
todoList.push(1, 2, 3)
15+
})
16+
17+
const doneFn = vi.fn().mockImplementation(async (use) => {
18+
await use(doneList)
19+
// cleanup
20+
doneFn.mockClear()
21+
doneList.length = 0
22+
})
23+
24+
const myTest = test
25+
.extend<{ todoList: number[] }>({
26+
todoList: todoFn,
27+
})
28+
.extend<{ doneList: number[]; archiveList: number[] }>({
29+
doneList: doneFn,
30+
archiveList,
31+
})
32+
33+
describe('test.extend()', () => {
34+
myTest('todoList and doneList', ({ todoList, doneList, archiveList }) => {
35+
expect(todoFn).toBeCalledTimes(1)
36+
expect(doneFn).toBeCalledTimes(1)
37+
38+
expectTypeOf(todoList).toEqualTypeOf<number[]>()
39+
expectTypeOf(doneList).toEqualTypeOf<number[]>()
40+
expectTypeOf(doneList).toEqualTypeOf<number[]>()
41+
42+
expect(todoList).toEqual([1, 2, 3])
43+
expect(doneList).toEqual([])
44+
expect(archiveList).toEqual([])
45+
46+
doneList.push(todoList.shift()!)
47+
expect(todoList).toEqual([2, 3])
48+
expect(doneList).toEqual([1])
49+
50+
doneList.push(todoList.shift()!)
51+
expect(todoList).toEqual([3])
52+
expect(doneList).toEqual([1, 2])
53+
54+
archiveList.push(todoList.shift()!)
55+
expect(todoList).toEqual([])
56+
expect(archiveList).toEqual([3])
57+
})
58+
59+
myTest('should called cleanup functions', ({ todoList, doneList, archiveList }) => {
60+
expect(todoList).toEqual([1, 2, 3])
61+
expect(doneList).toEqual([])
62+
expect(archiveList).toEqual([3])
63+
})
64+
65+
describe('smartly init fixtures', () => {
66+
myTest('should not init any fixtures', function () {
67+
expect(todoFn).not.toBeCalled()
68+
expect(doneFn).not.toBeCalled()
69+
70+
expectTypeOf(arguments[0].todoList).not.toEqualTypeOf<number[]>()
71+
expectTypeOf(arguments[0].doneList).not.toEqualTypeOf<number[]>()
72+
expectTypeOf(arguments[0].archiveList).not.toEqualTypeOf<number[]>()
73+
74+
expect(arguments[0].todoList).toBeUndefined()
75+
expect(arguments[0].doneList).toBeUndefined()
76+
expect(arguments[0].archiveList).toBeUndefined()
77+
})
78+
79+
myTest('should not init any fixtures', function ({}) {
80+
expect(todoFn).not.toBeCalled()
81+
expect(doneFn).not.toBeCalled()
82+
83+
expectTypeOf(arguments[0].todoList).not.toEqualTypeOf<number[]>()
84+
expectTypeOf(arguments[0].doneList).not.toEqualTypeOf<number[]>()
85+
expectTypeOf(arguments[0].archiveList).not.toEqualTypeOf<number[]>()
86+
87+
expect(arguments[0].todoList).toBeUndefined()
88+
expect(arguments[0].doneList).toBeUndefined()
89+
expect(arguments[0].archiveList).toBeUndefined()
90+
})
91+
92+
myTest('should only init todoList', function ({ todoList }) {
93+
expect(todoFn).toBeCalledTimes(1)
94+
expect(doneFn).not.toBeCalled()
95+
96+
expectTypeOf(todoList).toEqualTypeOf<number[]>()
97+
expectTypeOf(arguments[0].doneList).not.toEqualTypeOf<number[]>()
98+
expectTypeOf(arguments[0].archiveList).not.toEqualTypeOf<number[]>()
99+
100+
expect(arguments[0].doneList).toBeUndefined()
101+
expect(arguments[0].archiveList).toBeUndefined()
102+
})
103+
104+
myTest('should only init todoList and doneList', function ({ todoList, doneList }) {
105+
expect(todoFn).toBeCalledTimes(1)
106+
expect(doneFn).toBeCalledTimes(1)
107+
108+
expectTypeOf(todoList).toEqualTypeOf<number[]>()
109+
expectTypeOf(doneList).toEqualTypeOf<number[]>()
110+
expectTypeOf(arguments[0].archiveList).not.toEqualTypeOf<number[]>()
111+
112+
expect(todoList).toEqual([1, 2, 3])
113+
expect(doneList).toEqual([])
114+
expect(arguments[0].archiveList).toBeUndefined()
115+
})
116+
117+
myTest('should init all fixtures', ({ todoList, ...rest }) => {
118+
expect(todoFn).toBeCalledTimes(1)
119+
expect(doneFn).toBeCalledTimes(1)
120+
121+
expectTypeOf(todoList).toEqualTypeOf<number[]>()
122+
expectTypeOf(rest.doneList).toEqualTypeOf<number[]>()
123+
expectTypeOf(rest.archiveList).toEqualTypeOf<number[]>()
124+
125+
expect(todoList).toEqual([1, 2, 3])
126+
expect(rest.doneList).toEqual([])
127+
expect(rest.archiveList).toEqual([3])
128+
})
129+
130+
myTest('should init all fixtures', (context) => {
131+
expect(todoFn).toBeCalledTimes(1)
132+
expect(doneFn).toBeCalledTimes(1)
133+
134+
expectTypeOf(context.todoList).toEqualTypeOf<number[]>()
135+
expectTypeOf(context.doneList).toEqualTypeOf<number[]>()
136+
expectTypeOf(context.archiveList).toEqualTypeOf<number[]>()
137+
138+
expect(context.todoList).toEqual([1, 2, 3])
139+
expect(context.doneList).toEqual([])
140+
expect(context.archiveList).toEqual([3])
141+
})
142+
})
143+
})

0 commit comments

Comments
 (0)
Please sign in to comment.