Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d189936
feat(runner): implement mergeTests for composing TestAPI fixtures
Ujjwaljain16 Feb 14, 2026
0277013
Merge branch 'main' into feat/merge-tests
Ujjwaljain16 Feb 14, 2026
6446b39
fix(runner): export mergeTests from runner package entry point
Ujjwaljain16 Feb 14, 2026
7652696
test: update exports snapshot with mergeTests
Ujjwaljain16 Feb 14, 2026
adf5044
feat(runner): implement nominal overloads for mergeTests API
Ujjwaljain16 Feb 16, 2026
2f95529
Merge branch 'main' into feat/merge-tests
Ujjwaljain16 Feb 16, 2026
fa60ae8
refactor(runner): simplify mergeTests to use linear extension chain
Ujjwaljain16 Feb 16, 2026
c6e3c77
fix(runner): resolve context leakage and mergeTests regression
Ujjwaljain16 Feb 16, 2026
02a34e9
feat(runner): add mergeTests utility and fix fixture context leakage
Ujjwaljain16 Feb 17, 2026
0fbe859
docs: fix multiple blank lines lint error
Ujjwaljain16 Feb 17, 2026
2672a0e
fix(runner): restore hierarchical suite override lookup and isolation
Ujjwaljain16 Feb 17, 2026
a131cf9
fix(runner): resolve merge conflicts and fix fixture override leakage…
Ujjwaljain16 Feb 17, 2026
58c9f5b
fix(runner): fix typecheck error in hooks.ts by relaxing FixtureProps…
Ujjwaljain16 Feb 17, 2026
8ecefd9
Merge branch 'main' into feat/merge-tests
Ujjwaljain16 Feb 17, 2026
d71197a
fix(runner): resolve CI regressions and type errors in mergeTests
Ujjwaljain16 Feb 17, 2026
ddf97bf
refactor(runner): simplify mergeTests implementation and restore base…
Ujjwaljain16 Feb 18, 2026
c39a69e
refactor(runner): align FixturePropsOptions with main branch baseline
Ujjwaljain16 Feb 18, 2026
a2f1534
Merge branch 'upstream/main' into feat/merge-tests
Ujjwaljain16 Feb 18, 2026
bb33ec0
docs(runner): improve mergeTests JSDoc with more detail
Ujjwaljain16 Feb 18, 2026
000fec2
docs: improve mergeTests documentation with variadic support detail
Ujjwaljain16 Feb 18, 2026
1ad69af
chore: fix linting issues and improve mergeTests JSDoc
Ujjwaljain16 Feb 18, 2026
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
15 changes: 15 additions & 0 deletions docs/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,21 @@ test('server uses correct port', ({ config, server }) => {
})
```

## mergeTests <Version>4.1.0</Version>

`mergeTests` utility allows you to merge multiple `TestAPI` instances into a single one. This is equivalent to calling `.extend()` repeatedly with the fixtures from each test.

```ts
import { mergeTests, test } from 'vitest'

// Combined test has fixtures from test, otherTest, and uiTest
const myTest = mergeTests(test, otherTest, uiTest)
```

`mergeTests` is variadic and accepts any number of test instances. If multiple tests define the same fixture name, the one from the later test overrides the earlier one.

See [Merging Test Contexts](/guide/test-context#merging-test-contexts) for more details.

## test.override <Version>4.1.0</Version> {#test-override}

Use `test.override` to override fixture values for all tests within the current suite and its nested suites. This must be called at the top level of a `describe` block. See [Overriding Fixture Values](/guide/test-context.html#overriding-fixture-values) for more information.
Expand Down
35 changes: 35 additions & 0 deletions docs/guide/test-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,41 @@ Note that you cannot introduce new fixtures inside `test.override`. Extend the t
`test.scoped` is deprecated in favor of `test.override`. The `test.scoped` API still works but will be removed in a future version.
:::

### Merging Test Contexts <Version>4.1.0</Version> {#merging-test-contexts}

Vitest allows you to merge test contexts from multiple sources using the `mergeTests` utility. This is useful when you have separate test extensions (e.g., one for database, one for network, and one for UI) and want to combine them into a single test API.

`mergeTests` is variadic and accepts any number of test instances. Calling `mergeTests(testA, testB, testC)` is equivalent to calling `testA.extend(fixturesFromB).extend(fixturesFromC)`.

```ts
import { test as base, mergeTests } from 'vitest'

const dbTest = base.extend({
db: async ({}, use) => {
// ... setup db
await use(db)
// ... teardown db
}
})

const serverTest = base.extend({
server: async ({}, use) => {
// ... setup server
await use(server)
// ... teardown server
}
})

// Combined test has both `db` and `server` fixtures
const test = mergeTests(dbTest, serverTest)

test('uses both db and server', ({ db, server }) => {
// ...
})
```

If multiple tests define the same fixture name, the one from the later test (further to the right in the arguments) overrides the earlier one.

### Type-Safe Hooks

When using `test.extend`, the extended `test` object provides type-safe hooks that are aware of the extended context:
Expand Down
15 changes: 14 additions & 1 deletion packages/runner/src/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,26 @@ export class TestFixtures {
TestFixtures._definitions.push(this)
}

extend(runner: VitestRunner, userFixtures: UserFixtures): TestFixtures {
extend(runner: VitestRunner, userFixtures: UserFixtures | TestFixtures): TestFixtures {
const { suite } = getCurrentSuite()
const isTopLevel = !suite || suite.file === suite

if (userFixtures instanceof TestFixtures) {
const registrations = new Map(this._registrations)
for (const [name, item] of userFixtures._registrations) {
registrations.set(name, item)
}
return new TestFixtures(registrations)
}

const registrations = this.parseUserFixtures(runner, userFixtures, isTopLevel)
return new TestFixtures(registrations)
}

getFixtures(): TestFixtures {
Copy link
Member

@sheremet-va sheremet-va Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this? Why do you need this if you already have access to the instance?

return this
}

get(suite: Suite): FixtureRegistrations {
let currentSuite: Suite | undefined = suite
while (currentSuite) {
Expand Down
1 change: 1 addition & 0 deletions packages/runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
describe,
getCurrentSuite,
it,
mergeTests,
suite,
test,
} from './suite'
Expand Down
47 changes: 45 additions & 2 deletions packages/runner/src/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ function createDefaultSuite(runner: VitestRunner) {
if (config.concurrent != null) {
options.concurrent = config.concurrent
}
const collector = suite('', options, () => {})
const collector = suite('', options, () => { })
// no parent suite for top-level tests
delete collector.suite
return collector
Expand Down Expand Up @@ -302,7 +302,7 @@ function parseArguments<T extends (...args: any[]) => any>(
// implementations
function createSuiteCollector(
name: string,
factory: SuiteFactory = () => {},
factory: SuiteFactory = () => { },
mode: RunMode,
each?: boolean,
suiteOptions?: SuiteOptions,
Expand Down Expand Up @@ -1092,3 +1092,46 @@ function formatTemplateString(cases: any[], args: any[]): any[] {
}
return res
}

/**
* Merges multiple test instances into a single test instance.
*
* This is equivalent to calling `.extend()` repeatedly with the fixtures from each test.
* All fixtures from the provided tests will be available in the returned test.
* If multiple tests define the same fixture name, the one from the later test wins.
*
* @example
* const test = mergeTests(dbTest, serverTest, uiTest)
*/
export function mergeTests<A>(a: TestAPI<A>): TestAPI<A>
export function mergeTests<A, B>(a: TestAPI<A>, b: TestAPI<B>): TestAPI<A & B>
export function mergeTests<A, B, C>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>): TestAPI<A & B & C>
export function mergeTests<A, B, C, D>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>, d: TestAPI<D>): TestAPI<A & B & C & D>
export function mergeTests<A, B, C, D, E>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>, d: TestAPI<D>, e: TestAPI<E>): TestAPI<A & B & C & D & E>
export function mergeTests<A, B, C, D, E, F>(a: TestAPI<A>, b: TestAPI<B>, c: TestAPI<C>, d: TestAPI<D>, e: TestAPI<E>, f: TestAPI<F>): TestAPI<A & B & C & D & E & F>
export function mergeTests(...tests: TestAPI<any>[]): TestAPI<any> {
if (tests.length === 0) {
throw new TypeError('mergeTests requires at least one test')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not tested

}

// Use the first test as the base
let [currentTest, ...rest] = tests

for (const nextTest of rest) {
const nextContext = getChainableContext(nextTest)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not tested

if (!nextContext || typeof nextContext.getFixtures !== 'function') {
throw new TypeError('mergeTests requires extended test instances created via test.extend()')
}

// Extract fixtures from the next test and extend the current test
// This behaves exactly like currentTest.extend(nextFixtures)
const currentContext = getChainableContext(currentTest)
if (!currentContext) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not tested

throw new TypeError('Cannot merge tests: base test is not a valid test instance')
}
const fixtures = nextContext.getFixtures()
currentTest = currentTest.extend(fixtures as any)
}

return currentTest
}
1 change: 1 addition & 0 deletions packages/vitest/src/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export {
suite,
test,
} from '@vitest/runner'
export { mergeTests } from '@vitest/runner'
export type {
ImportDuration,
OnTestFailedHandler,
Expand Down
1 change: 1 addition & 0 deletions test/core/test/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ it('exports snapshot', async ({ skip, task }) => {
"expectTypeOf": "function",
"inject": "function",
"it": "function",
"mergeTests": "function",
"onTestFailed": "function",
"onTestFinished": "function",
"recordArtifact": "function",
Expand Down
42 changes: 42 additions & 0 deletions test/core/test/merge-tests.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expectTypeOf, mergeTests, test } from 'vitest'

const testA = test.extend({
a: 1,
})

const testB = test.extend({
b: 2,
})

const merged = mergeTests(testA, testB)

merged('types', ({ a, b }) => {
expectTypeOf(a).not.toBeAny()
expectTypeOf(b).not.toBeAny()
expectTypeOf(a).toEqualTypeOf<number>()
expectTypeOf(b).toEqualTypeOf<number>()
})

const testC = test.extend({
c: 'string',
})

const merged3 = mergeTests(merged, testC)

merged3('chained merge types', ({ a, b, c }) => {
expectTypeOf(a).toBeNumber()
expectTypeOf(b).toBeNumber()
expectTypeOf(c).toBeString()
})

const testBool = test.extend({
d: true,
})

const mergedVariadic = mergeTests(testA, testB, testBool)

mergedVariadic('variadic types', ({ a, b, d }) => {
expectTypeOf(a).toBeNumber()
expectTypeOf(b).toBeNumber()
expectTypeOf(d).toBeBoolean()
})
122 changes: 122 additions & 0 deletions test/core/test/merge-tests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, mergeTests, test } from 'vitest'

describe('mergeTests', () => {
const testA = test.extend({
a: 1,
})
const testB = test.extend({
b: 2,
})
const merged = mergeTests(testA, testB)

merged('merges fixtures from two tests', ({ a, b }) => {
expect(a).toBe(1)
expect(b).toBe(2)
})

describe('nested describe', () => {
const testC = test.extend({
a: 2,
})
const mergedOverride = mergeTests(testA, testC)

mergedOverride('overrides fixtures', ({ a }) => {
expect(a).toBe(2)
})
})

const mergedReverse = mergeTests(testB, testA)

mergedReverse('overrides fixtures (reverse)', ({ a }) => {
expect(a).toBe(1)
})

const testD = test.extend({
c: 3,
})

const mergedChained = mergeTests(mergeTests(testA, testB), testD)

mergedChained('chained merge', ({ a, b, c }) => {
expect(a).toBe(1)
expect(b).toBe(2)
expect(c).toBe(3)
})

describe('shared base', () => {
const base = test.extend({
base: 'base',
})
const derivedA = base.extend({
a: 'a',
})
const derivedB = base.extend({
b: 'b',
})
const mergedDerived = mergeTests(derivedA, derivedB)

mergedDerived('shared base fixtures', ({ base, a, b }) => {
expect(base).toBe('base')
expect(a).toBe('a')
expect(b).toBe('b')
})
})

describe('variadic merge (A, B, C) overrides correctly', () => {
const testA = test.extend({
a: 1,
shared: 'a',
})
const testB = test.extend({
b: 2,
shared: 'b',
})
const testC = test.extend({
c: 3,
shared: 'c',
})

const merged = mergeTests(testA, testB, testC)

merged('inherits all fixtures and overrides from last', ({ a, b, c, shared }) => {
expect(a).toBe(1)
expect(b).toBe(2)
expect(c).toBe(3)
expect(shared).toBe('c')
})
})

describe('overrides', () => {
describe('top-level', () => {
const base = test.extend({ a: 1 })
base.override({ a: 2 })
const merged = mergeTests(base)

merged('confirms top-level overrides are respected', ({ a }) => {
expect(a).toBe(2)
})
})

describe('overrides are dropped during merge (extend semantics)', () => {
const base = test.extend({ a: 1 })
base.override({ a: 2 })
const other = test.extend({ b: 3 })
const merged = mergeTests(base, other)

merged('overrides are reset like extend()', ({ a, b }) => {
expect(a).toBe(1)
expect(b).toBe(3)
})
})

describe('scoped (identity)', () => {
const base = test.extend({ a: 1 })
base.override({ a: 2 })
const merged = mergeTests(base)

merged('confirms scoped overrides are preserved', ({ a }) => {
expect(a).toBe(2)
})
})
})
})
Loading