Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Vitest is a next-generation testing framework powered by Vite. This is a monorep
- **Core directory test**: `CI=true pnpm test <test-file>` (for `test/core`)
- **Browser tests**: `CI=true pnpm test:browser:playwright` or `CI=true pnpm test:browser:webdriverio`

When writing tests, AVOID using `toContain` for validation. Prefer using `toMatchSnapshot` to include the test error and its stack.
When writing tests, AVOID using `toContain` for validation. Prefer using `toMatchInlineSnapshot` to include the test error and its stack. If snapshot is failing, update the snapshot instead of reverting it to `toContain`.

If you need to typecheck tests, run `pnpm typecheck` from the root of the workspace.

Expand Down
74 changes: 73 additions & 1 deletion docs/guide/test-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -800,7 +800,7 @@ Note that you cannot introduce new fixtures inside `test.override`. Extend the t

### Type-Safe Hooks

When using `test.extend`, the extended `test` object provides type-safe `beforeEach` and `afterEach` hooks that are aware of the new context:
When using `test.extend`, the extended `test` object provides type-safe hooks that are aware of the extended context:

```ts
const test = baseTest
Expand All @@ -815,3 +815,75 @@ test.afterEach(({ counter }) => {
console.log('Final count:', counter.value)
})
```

#### Suite-Level Hooks with Fixtures <Version>4.1.0</Version> {#suite-level-hooks}

The extended `test` object also provides `beforeAll`, `afterAll`, and `aroundAll` hooks that can access file-scoped and worker-scoped fixtures:

```ts
const test = baseTest
.extend('config', { scope: 'file' }, () => loadConfig())
.extend('database', { scope: 'file' }, async ({ config }, onCleanup) => {
const db = await createDatabase(config)
onCleanup(() => db.close())
return db
})

// Access file-scoped fixtures in suite-level hooks
test.beforeAll(async ({ database }) => {
await database.migrate()
})

test.afterAll(async ({ database }) => {
await database.cleanup()
})

test.aroundAll(async ({ database }, run) => {
await database.beginTransaction()
await run()
await database.rollbackTransaction()
})
```

::: warning IMPORTANT
Suite-level hooks (`beforeAll`, `afterAll`, `aroundAll`) **must be called on the `test` object returned from `test.extend()`** to have access to the extended fixtures. Using the global `beforeAll`/`afterAll`/`aroundAll` functions will not have access to your custom fixtures:

```ts
import { test as baseTest, beforeAll } from 'vitest'

const test = baseTest
.extend('database', { scope: 'file' }, async ({}, onCleanup) => {
const db = await createDatabase()
onCleanup(() => db.close())
return db
})

// ❌ WRONG: Global beforeAll doesn't have access to 'database'
beforeAll(({ database }) => {
// Error: 'database' is undefined
})

// ✅ CORRECT: Use test.beforeAll to access fixtures
test.beforeAll(({ database }) => {
// 'database' is available
})
```

This applies to all suite-level hooks: `beforeAll`, `afterAll`, and `aroundAll`.
:::

::: tip
Suite-level hooks can only access **file-scoped** and **worker-scoped** fixtures. Test-scoped fixtures are not available in these hooks because they run outside the context of individual tests. If you try to access a test-scoped fixture in a suite-level hook, Vitest will throw an error.

```ts
const test = baseTest
.extend('testFixture', () => 'test-scoped')
.extend('fileFixture', { scope: 'file' }, () => 'file-scoped')

// ❌ Error: test-scoped fixtures not available in beforeAll
test.beforeAll(({ testFixture }) => {})

// ✅ Works: file-scoped fixtures are available
test.beforeAll(({ fileFixture }) => {})
```
:::
8 changes: 7 additions & 1 deletion packages/runner/src/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { File, SuiteHooks } from './types/tasks'
import { processError } from '@vitest/utils/error' // TODO: load dynamically
import { toArray } from '@vitest/utils/helpers'
import { collectorContext, setFileContext } from './context'
import { getHooks, setHooks } from './map'
import { getHooks, getSuiteContext, setHooks, setSuiteContext } from './map'
import { runSetupFiles } from './setup'
import {
clearCollectorContext,
Expand Down Expand Up @@ -79,6 +79,12 @@ export async function collectTests(

const defaultTasks = await getDefaultSuite().collect(file)

// Copy suite context from default suite to file for beforeAll/afterAll/aroundAll hooks
const defaultSuiteContext = getSuiteContext(defaultTasks)
if (defaultSuiteContext) {
setSuiteContext(file, defaultSuiteContext)
}

const fileHooks = createSuiteHooks()
mergeHooks(fileHooks, getHooks(defaultTasks))

Expand Down
72 changes: 60 additions & 12 deletions packages/runner/src/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { VitestRunner } from './types'
import type { FixtureOptions, TestContext } from './types/tasks'
import type { File, FixtureOptions, TestContext } from './types/tasks'
import { createDefer, filterOutComments, isObject } from '@vitest/utils/helpers'
import { getFileContext } from './context'
import { getTestFixture } from './map'
Expand Down Expand Up @@ -175,10 +175,38 @@ export async function callFixtureCleanupFrom(context: object, fromIndex: number)
cleanupFnArray.length = fromIndex
}

export function withFixtures(runner: VitestRunner, fn: Function, testContext?: TestContext) {
export interface WithFixturesOptions {
/**
* Whether this is a suite-level hook (beforeAll/afterAll/aroundAll).
* Suite hooks can only access file/worker scoped fixtures and static values.
*/
isSuiteHook?: boolean
/**
* The original function to parse for fixture props.
* Use this when wrapping the function and the wrapper has different signature.
*/
originalFn?: Function
/**
* The index of the argument that contains fixtures (for functions where
* fixtures are not in the first argument, like aroundEach/aroundAll).
*/
contextArgumentIndex?: number
/**
* The test context to use. If not provided, the hookContext passed to the
* returned function will be used.
*/
context?: TestContext
/**
* Error with stack trace captured at hook registration time.
* Used to provide better error messages with proper stack traces.
*/
stackTraceError?: Error
}

export function withFixtures(runner: VitestRunner, fn: Function, file: File, options?: WithFixturesOptions) {
return (hookContext?: TestContext): any => {
const context: (TestContext & { [key: string]: any }) | undefined
= hookContext || testContext
= hookContext || options?.context

if (!context) {
return fn({})
Expand All @@ -189,7 +217,9 @@ export function withFixtures(runner: VitestRunner, fn: Function, testContext?: T
return fn(context)
}

const usedProps = getUsedProps(fn)
const fnToAnalyze = options?.originalFn ?? fn
const argumentIndex = (fn as any).__VITEST_FIXTURE_INDEX__ ?? options?.contextArgumentIndex
const usedProps = getUsedProps(fnToAnalyze, argumentIndex)
const hasAutoFixture = fixtures.some(({ auto }) => auto)
if (!usedProps.length && !hasAutoFixture) {
return fn(context)
Expand All @@ -211,6 +241,25 @@ export function withFixtures(runner: VitestRunner, fn: Function, testContext?: T
)
const pendingFixtures = resolveDeps(usedFixtures)

// Check if suite-level hook is trying to access test-scoped fixtures
// Suite hooks (beforeAll/afterAll/aroundAll) can only access file/worker scoped fixtures
if (options?.isSuiteHook) {
const testScopedFixtures = pendingFixtures.filter(f => f.scope === 'test' && f.isFn)
if (testScopedFixtures.length > 0) {
const fixtureNames = testScopedFixtures.map(f => `"${f.prop}"`).join(', ')
const error = new Error(
`[@vitest/runner] Test-scoped fixtures cannot be used in beforeAll/afterAll/aroundAll hooks. `
+ `The following fixtures are test-scoped: ${fixtureNames}. `
+ `Use file or worker scoped fixtures instead, or move the logic to beforeEach/afterEach hooks.`,
)
// Use stack trace from hook registration for better error location
if (options.stackTraceError?.stack) {
error.stack = error.message + options.stackTraceError.stack.replace(options.stackTraceError.message, '')
}
throw error
}
}

if (!pendingFixtures.length) {
return fn(context)
}
Expand All @@ -227,6 +276,7 @@ export function withFixtures(runner: VitestRunner, fn: Function, testContext?: T
fixture,
context!,
cleanupFnArray,
file,
)
context![fixture.prop] = resolvedValue
fixtureValueMap.set(fixture, resolvedValue)
Expand All @@ -250,8 +300,9 @@ function resolveFixtureValue(
fixture: FixtureItem,
context: TestContext & { [key: string]: any },
cleanupFnArray: (() => void | Promise<void>)[],
file: File,
) {
const fileContext = getFileContext(context.task.file)
const fileContext = getFileContext(file)
const workerContext = runner.getWorkerContext?.()

if (!fixture.isFn) {
Expand Down Expand Up @@ -385,7 +436,7 @@ function resolveDeps(
return pendingFixtures
}

function getUsedProps(fn: Function) {
function getUsedProps(fn: Function, fixtureIndex: number = 0) {
let fnString = filterOutComments(fn.toString())
// match lowered async function and strip it off
// example code on esbuild-try https://esbuild.github.io/try/#YgAwLjI0LjAALS1zdXBwb3J0ZWQ6YXN5bmMtYXdhaXQ9ZmFsc2UAZQBlbnRyeS50cwBjb25zdCBvID0gewogIGYxOiBhc3luYyAoKSA9PiB7fSwKICBmMjogYXN5bmMgKGEpID0+IHt9LAogIGYzOiBhc3luYyAoYSwgYikgPT4ge30sCiAgZjQ6IGFzeW5jIGZ1bmN0aW9uKGEpIHt9LAogIGY1OiBhc3luYyBmdW5jdGlvbiBmZihhKSB7fSwKICBhc3luYyBmNihhKSB7fSwKCiAgZzE6IGFzeW5jICgpID0+IHt9LAogIGcyOiBhc3luYyAoeyBhIH0pID0+IHt9LAogIGczOiBhc3luYyAoeyBhIH0sIGIpID0+IHt9LAogIGc0OiBhc3luYyBmdW5jdGlvbiAoeyBhIH0pIHt9LAogIGc1OiBhc3luYyBmdW5jdGlvbiBnZyh7IGEgfSkge30sCiAgYXN5bmMgZzYoeyBhIH0pIHt9LAoKICBoMTogYXN5bmMgKCkgPT4ge30sCiAgLy8gY29tbWVudCBiZXR3ZWVuCiAgaDI6IGFzeW5jIChhKSA9PiB7fSwKfQ
Expand All @@ -405,12 +456,9 @@ function getUsedProps(fn: Function) {
return []
}

let first = args[0]
if ('__VITEST_FIXTURE_INDEX__' in fn) {
first = args[(fn as any).__VITEST_FIXTURE_INDEX__]
if (!first) {
return []
}
const first = args[fixtureIndex]
if (!first) {
return []
}

if (!(first[0] === '{' && first.endsWith('}'))) {
Expand Down
Loading
Loading