diff --git a/docs/advanced/api/test-case.md b/docs/advanced/api/test-case.md index 40fae5f57bae..71dc2499c357 100644 --- a/docs/advanced/api/test-case.md +++ b/docs/advanced/api/test-case.md @@ -125,8 +125,17 @@ Checks if the test did not fail the suite. If the test is not finished yet or wa function meta(): TaskMeta ``` -Custom [metadata](/advanced/metadata) that was attached to the test during its execution. The meta can be attached by assigning a property to the `ctx.task.meta` object during a test run: +Custom [metadata](/advanced/metadata) that was attached to the test during its execution or defined in test options. The meta can be defined in multiple ways: +**Using test options** (available since Vitest 4.0): +```ts {1} +test('the validation works correctly', { meta: { component: 'auth', priority: 'high' } }, ({ task }) => { + console.log(task.meta.component) // 'auth' + console.log(task.meta.priority) // 'high' +}) +``` + +**Runtime assignment during test execution**: ```ts {3,6} import { test } from 'vitest' @@ -137,7 +146,9 @@ test('the validation works correctly', ({ task }) => { }) ``` -If the test did not finish running yet, the meta will be an empty object. +Metadata from test options is merged with suite options and runtime assignments. See the [metadata documentation](/advanced/metadata) for details on merging behavior. + +If the test did not finish running yet, the meta will contain only the metadata from options (if any). ## result diff --git a/docs/advanced/api/test-suite.md b/docs/advanced/api/test-suite.md index bc3e2ab4de8b..0aaa1b00121b 100644 --- a/docs/advanced/api/test-suite.md +++ b/docs/advanced/api/test-suite.md @@ -197,8 +197,22 @@ Note that errors are serialized into simple objects: `instanceof Error` will alw function meta(): TaskMeta ``` -Custom [metadata](/advanced/metadata) that was attached to the suite during its execution or collection. The meta can be attached by assigning a property to the `task.meta` object during a test run: +Custom [metadata](/advanced/metadata) that was attached to the suite during its execution, collection, or defined in describe options. The meta can be defined in multiple ways: +**Using describe options** (available since Vitest 4.0): +```ts {1} +describe('the validation works correctly', { meta: { component: 'auth', area: 'validation' } }, () => { + test('some test', ({ task }) => { + console.log(task.suite.meta.component) // 'auth' + console.log(task.suite.meta.area) // 'validation' + // Tests inherit suite metadata automatically + console.log(task.meta.component) // 'auth' + console.log(task.meta.area) // 'validation' + }) +}) +``` + +**Runtime assignment during collection or test execution**: ```ts {5,10} import { test } from 'vitest' @@ -214,6 +228,8 @@ describe('the validation works correctly', (task) => { }) ``` +Suite metadata from options is automatically inherited by child tests and can be merged with test-specific metadata. See the [metadata documentation](/advanced/metadata) for details on merging behavior. + :::tip If metadata was attached during collection (outside of the `test` function), then it will be available in [`onTestModuleCollected`](./reporters#ontestmodulecollected) hook in the custom reporter. ::: diff --git a/docs/advanced/metadata.md b/docs/advanced/metadata.md index 883eb70c3d10..9f80e3db390f 100644 --- a/docs/advanced/metadata.md +++ b/docs/advanced/metadata.md @@ -20,6 +20,78 @@ test('custom', ({ task }) => { }) ``` +## Defining Metadata in Test Options + +Since Vitest 4.0, you can also define metadata directly in the test options, which will be merged with metadata from all ancestor suites in the hierarchy: + +```ts +describe('suite', { meta: { suiteLevel: 'parent', priority: 'medium' } }, () => { + test('with meta options', { meta: { testLevel: 'child', priority: 'high' } }, ({ task }) => { + // task.meta contains merged metadata: + // { suiteLevel: 'parent', testLevel: 'child', priority: 'high' } + // Note: test meta overrides suite meta when there are conflicts + console.log(task.meta.suiteLevel) // 'parent' + console.log(task.meta.testLevel) // 'child' + console.log(task.meta.priority) // 'high' (test overrides suite) + }) + + test('inherits suite meta', ({ task }) => { + // task.meta only contains suite metadata: + // { suiteLevel: 'parent', priority: 'medium' } + console.log(task.meta.suiteLevel) // 'parent' + console.log(task.meta.priority) // 'medium' + }) +}) +``` + +For nested describe blocks, metadata cascades through all levels of the hierarchy: + +```ts +describe('Grandparent Suite', { meta: { level: 'root', priority: 'low' } }, () => { + describe('Parent Suite', { meta: { level: 'middle', priority: 'medium' } }, () => { + test('deeply nested test', ({ task }) => { + // task.meta contains metadata from all ancestor suites: + // { level: 'middle', priority: 'medium' } + // Note: closer ancestors override distant ancestors + console.log(task.meta.level) // 'middle' (parent overrides grandparent) + console.log(task.meta.priority) // 'medium' (parent overrides grandparent) + }) + }) +}) +``` + +The metadata merging follows this priority order (lowest to highest): +1. Distant ancestor suite `meta` options (e.g., grandparent suites) +2. Closer ancestor suite `meta` options (e.g., parent suites) +3. Test-level `meta` options +4. Runtime modifications via `task.meta` + +## Accessing Suite vs Test Metadata + +While tests get merged metadata in `task.meta`, the original suite metadata is preserved separately: + +```ts +describe('suite', { meta: { component: 'auth', area: 'validation' } }, () => { + test('example', { meta: { testType: 'integration' } }, ({ task }) => { + // Merged metadata (suite + test) + console.log(task.meta) + // { component: 'auth', area: 'validation', testType: 'integration' } + + // Original suite metadata only + console.log(task.suite.meta) + // { component: 'auth', area: 'validation' } + + // They are different objects + console.log(task.meta !== task.suite.meta) // true + }) +}) +``` + +This separation allows you to: +- Access the test's complete merged metadata via `task.meta` +- Access the suite's original metadata via `task.suite.meta` +- Distinguish between suite-level and test-level metadata in reporters and custom logic + Once a test is completed, Vitest will send a task including the result and `meta` to the Node.js process using RPC, and then report it in `onTestCaseResult` and other hooks that have access to tasks. To process this test case, you can utilize the `onTestCaseResult` method available in your reporter implementation: ```ts [custom-reporter.js] diff --git a/docs/api/index.md b/docs/api/index.md index 49bf6d7004a2..5d40f5dff228 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -29,6 +29,13 @@ interface TestOptions { * @default 0 */ repeats?: number + /** + * Custom metadata for the task. This will be merged with any meta property defined in the test. + * Values must be JSON serializable. + * + * @default undefined + */ + meta?: Partial } ``` @@ -733,6 +740,18 @@ bench.todo('unimplemented test') When you use `test` or `bench` in the top level of file, they are collected as part of the implicit suite for it. Using `describe` you can define a new suite in the current context, as a set of related tests or benchmarks and other nested suites. A suite lets you organize your tests and benchmarks so reports are more clear. +Like `test`, you can also provide options as a second argument to configure the suite behavior: + +```ts +import { describe, test } from 'vitest' + +describe('suite with options', { meta: { component: 'auth' } }, () => { + test('inherits suite metadata', ({ task }) => { + console.log(task.meta.component) // 'auth' + }) +}) +``` + ```ts // basic.spec.ts // organizing tests diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 301bb962ec88..d26b78ab5f35 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -190,6 +190,28 @@ function assert(condition: any, message: string) { } } +function collectAncestorMeta(suite: Suite | undefined): Record { + const ancestorMeta = Object.create(null) + let current = suite + + // Walk up the suite hierarchy and collect metadata + // We'll collect from root to child so that closer ancestors override distant ones + const suites: Suite[] = [] + while (current) { + if (current.meta) { + suites.unshift(current) // Add to beginning so we process from root to child + } + current = current.suite + } + + // Merge metadata with closer ancestors having higher priority + for (const s of suites) { + Object.assign(ancestorMeta, s.meta) + } + + return ancestorMeta +} + export function getDefaultSuite(): SuiteCollector { assert(defaultSuite, 'the default suite') return defaultSuite @@ -325,7 +347,10 @@ function createSuiteCollector( : options.todo ? 'todo' : 'run', - meta: options.meta ?? Object.create(null), + meta: { + ...collectAncestorMeta(collectorContext.currentSuite?.suite), + ...(options.meta || {}), + }, annotations: [], } const handler = options.handler @@ -457,7 +482,9 @@ function createSuiteCollector( file: undefined!, shuffle: suiteOptions?.shuffle, tasks: [], - meta: Object.create(null), + meta: { + ...(suiteOptions?.meta || {}), + }, concurrent: suiteOptions?.concurrent, } diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index fa2ae30c519d..07e2d4327e43 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -485,6 +485,10 @@ export interface TestOptions { * Whether the test is expected to fail. If it does, the test will pass, otherwise it will fail. */ fails?: boolean + /** + * Custom metadata for the task. This will be merged with any meta property defined in the test. + */ + meta?: Partial } interface ExtendedAPI { @@ -637,7 +641,7 @@ export interface TaskCustomOptions extends TestOptions { /** * Custom metadata for the task that will be assigned to `task.meta`. */ - meta?: Record + meta?: Partial /** * Task fixtures. */ diff --git a/test/core/test/test-meta-options.test.ts b/test/core/test/test-meta-options.test.ts new file mode 100644 index 000000000000..78d4228ef021 --- /dev/null +++ b/test/core/test/test-meta-options.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, test } from 'vitest' + +describe('TestOptions meta property functionality', { meta: { suiteLevel: 'test-suite', priority: 'medium' } }, () => { + let beforeEachMeta: Record + + beforeEach(({ task }) => { + beforeEachMeta = { ...task.meta } + }) + + test('should merge suite and test meta properties', { meta: { testLevel: 'individual-test', priority: 'high' } }, ({ task }) => { + // Test should have both suite and test meta + expect(task.meta).toMatchObject({ + suiteLevel: 'test-suite', + testLevel: 'individual-test', + priority: 'high', // test meta should override suite meta + }) + + // beforeEach should have access to merged meta + expect(beforeEachMeta).toMatchObject({ + suiteLevel: 'test-suite', + testLevel: 'individual-test', + priority: 'high', + }) + }) + + test('should inherit suite meta when no test meta provided', ({ task }) => { + // Test should only have suite meta + expect(task.meta).toMatchObject({ + suiteLevel: 'test-suite', + priority: 'medium', + }) + + // beforeEach should have access to suite meta + expect(beforeEachMeta).toMatchObject({ + suiteLevel: 'test-suite', + priority: 'medium', + }) + }) + + test('should allow adding meta at runtime', { meta: { testLevel: 'runtime-test' } }, ({ task }) => { + // Add meta at runtime + task.meta.runtimeAdded = 'added-during-test' + + expect(task.meta).toMatchObject({ + suiteLevel: 'test-suite', + testLevel: 'runtime-test', + priority: 'medium', + runtimeAdded: 'added-during-test', + }) + }) + + test('should differentiate between task.meta and task.suite.meta', { meta: { testLevel: 'child-test', priority: 'high' } }, ({ task }) => { + // task.meta should contain merged metadata (suite + test) + expect(task.meta).toMatchObject({ + suiteLevel: 'test-suite', + testLevel: 'child-test', + priority: 'high', // test overrides suite + }) + + // task.suite.meta should contain only suite's own metadata + expect(task.suite?.meta).toMatchObject({ + suiteLevel: 'test-suite', + priority: 'medium', // original suite priority + }) + + // They should be different objects + expect(task.meta).not.toBe(task.suite?.meta) + + // task.suite.meta should NOT have test-specific metadata + expect(task.suite?.meta).not.toHaveProperty('testLevel') + }) +}) + +describe('Suite without meta', () => { + let beforeEachMeta: Record + + beforeEach(({ task }) => { + beforeEachMeta = { ...task.meta } + }) + + test('should only have test meta when suite has no meta', { meta: { testOnly: 'test-meta' } }, ({ task }) => { + expect(task.meta).toMatchObject({ + testOnly: 'test-meta', + }) + + expect(beforeEachMeta).toMatchObject({ + testOnly: 'test-meta', + }) + }) +}) + +describe('Nested describes metadata cascading', { meta: { grandparent: 'top-level', priority: 'low' } }, () => { + describe('Middle suite', { meta: { parent: 'middle-level', priority: 'medium' } }, () => { + test('should cascade metadata from all ancestor suites', ({ task }) => { + // Should now get metadata from all ancestors: grandparent + parent + expect(task.meta).toMatchObject({ + grandparent: 'top-level', // from grandparent suite + parent: 'middle-level', // from parent suite + priority: 'medium', // parent overrides grandparent + }) + + // Original suite metadata should be preserved + expect(task.suite?.meta).toMatchObject({ + parent: 'middle-level', + priority: 'medium', + }) + + // Grandparent suite metadata should also be preserved + expect(task.suite?.suite?.meta).toMatchObject({ + grandparent: 'top-level', + priority: 'low', + }) + }) + + test('test metadata should override cascaded suite metadata', { meta: { testLevel: 'child', priority: 'highest' } }, ({ task }) => { + // Should get metadata from all ancestors plus test metadata + expect(task.meta).toMatchObject({ + grandparent: 'top-level', // from grandparent suite + parent: 'middle-level', // from parent suite + testLevel: 'child', // from test + priority: 'highest', // test overrides all ancestors + }) + }) + }) +})