Skip to content

Commit

Permalink
feat: support accessing other fixtures in fixture function (#3651)
Browse files Browse the repository at this point in the history
  • Loading branch information
fenghan34 committed Jul 3, 2023
1 parent d77f712 commit 1621cc6
Show file tree
Hide file tree
Showing 16 changed files with 395 additions and 94 deletions.
2 changes: 1 addition & 1 deletion docs/api/index.md
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions docs/guide/test-context.md
Expand Up @@ -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)

Expand Down Expand Up @@ -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])
}

Expand All @@ -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.
Expand Down
153 changes: 114 additions & 39 deletions 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<string, any>, 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<Record<string, any>>, context: Test<Record<string, any>>['context']) {
const props = getUsedFixtureProps(fn, Object.keys(fixtures))
return context
}

if (props.length === 0)
export function withFixtures(fn: Function, fixtures: FixtureItem[], context: TestContext & Record<string, any>) {
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<FixtureItem>(), 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
}
17 changes: 10 additions & 7 deletions packages/runner/src/suite.ts
Expand Up @@ -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()
Expand Down Expand Up @@ -232,7 +233,7 @@ function createSuite() {

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

testFn.extend = function (fixtures: Fixtures<Record<string, any>>) {
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) {
Expand Down
15 changes: 12 additions & 3 deletions packages/runner/src/types/tasks.ts
Expand Up @@ -182,11 +182,20 @@ export type TestAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> & {
each: TestEachFunction
skipIf(condition: any): ChainableTestAPI<ExtraContext>
runIf(condition: any): ChainableTestAPI<ExtraContext>
extend<T extends Record<string, any>>(fixtures: Fixtures<T>): TestAPI<ExtraContext & T>
extend<T extends Record<string, any> = {}>(fixtures: Fixtures<T, ExtraContext>): TestAPI<{
[K in keyof T | keyof ExtraContext]:
K extends keyof T ? T[K] :
K extends keyof ExtraContext ? ExtraContext[K] : never }>
}

export type Fixtures<T extends Record<string, any>> = {
[K in keyof T]: T[K] | ((use: (fixture: T[K]) => Promise<void>) => Promise<void>)
export type Fixtures<T extends Record<string, any>, 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<void>) => Promise<void>)
}

type ChainableSuiteAPI<ExtraContext = {}> = ChainableFunction<
Expand Down
6 changes: 4 additions & 2 deletions packages/runner/src/utils/chain.ts
Expand Up @@ -9,7 +9,6 @@ export type ChainableFunction<T extends string, Args extends any[], R = any, E =
export function createChainable<T extends string, Args extends any[], R = any, E = {}>(
keys: T[],
fn: (this: Record<T, any>, ...args: Args) => R,
initialContext?: Record<T, any>,
): ChainableFunction<T, Args, R, E> {
function create(context: Record<T, any>) {
const chain = function (this: any, ...args: Args) {
Expand All @@ -20,6 +19,9 @@ export function createChainable<T extends string, Args extends any[], R = any, E
chain.setContext = (key: T, value: any) => {
context[key] = value
}
chain.mergeContext = (ctx: Record<T, any>) => {
Object.assign(context, ctx)
}
for (const key of keys) {
Object.defineProperty(chain, key, {
get() {
Expand All @@ -30,7 +32,7 @@ export function createChainable<T extends string, Args extends any[], R = any, E
return chain
}

const chain = create(initialContext || {} as any) as any
const chain = create({} as any) as any
chain.fn = fn
return chain
}

0 comments on commit 1621cc6

Please sign in to comment.