Skip to content

Comments

feat(runner): add mergeTests utility to compose TestAPI fixtures#9662

Open
Ujjwaljain16 wants to merge 21 commits intovitest-dev:mainfrom
Ujjwaljain16:feat/merge-tests
Open

feat(runner): add mergeTests utility to compose TestAPI fixtures#9662
Ujjwaljain16 wants to merge 21 commits intovitest-dev:mainfrom
Ujjwaljain16:feat/merge-tests

Conversation

@Ujjwaljain16
Copy link
Contributor

Description

This adds a new mergeTests(testA, testB) utility that allows composing fixtures from multiple TestAPI instances.

Example:

const testA = test.extend({ a: 1 })
const testB = test.extend({ b: 2 })

const merged = mergeTests(testA, testB)

merged('works', ({ a, b }) => {
  // both fixtures available
})

Implementation

The implementation reuses the existing .extend() mechanism to preserve fixture graph integrity.

Instead of manually merging internal TestFixtures maps, the approach is:

  • Extract fixture definitions from testB via resolveFixtures()
  • Call testA.extend(fixturesFromB)
  • Return a new TestAPI<A & B>

This ensures:

  • Proper dependency resolution
  • Correct scope handling (test / worker)
  • Auto fixtures behave naturally
  • No mutation of internal state

Override Semantics

If both tests define the same fixture name, the second argument (testB) overrides the first.
This mirrors .extend() behavior (last-writer-wins).

Type Support

The return type is TestAPI<A & B>.
Type-level tests verify correct intersection inference and chained merging.

Tests

Added runtime tests covering:

  • Basic merging
  • Override behavior
  • Nested describe
  • Shared base extension
  • Chained merging

Added type-level tests verifying fixture intersection types.

All tests pass.


Resolves #9483

Tests

Run:

pnpm test:ci

Documentation

No public docs changes required. The utility is self-contained and exposed via public API.


Changesets

PR name follows conventional format:

feat(runner): add mergeTests utility to compose TestAPI fixtures

- Adds resolveFixtures() to TestFixtures
- Implements mergeTests(testA, testB) using existing .extend() mechanism
- Supports override semantics (last writer wins)
- Includes runtime tests and type-level tests
- Verified nested describe, shared base, and chained merges
@Ujjwaljain16
Copy link
Contributor Author

@sheremet-va waiting for the review

}

/**
* @internal
Copy link
Member

Choose a reason for hiding this comment

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

Everything here is internal, so no need to add this

/**
* @internal
*/
resolveFixtures(): UserFixtures {
Copy link
Member

@sheremet-va sheremet-va Feb 15, 2026

Choose a reason for hiding this comment

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

I don't like this idea, why do we need to convert already converted fixtures into user definitions and then convert them back again?

I would do the opposite and add a merge function that accepts another Fixtures, then we can just iterate over the registrations overriding them

export function mergeTests<A, B>(
test: TestAPI<A>,
testB: TestAPI<B>,
): TestAPI<A & B> {
Copy link
Member

Choose a reason for hiding this comment

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

Hm, I assumed it can accept more than 2 (any number in fact). Would it make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed this utility should support composing any number of test APIs.

I updated the signature to:

mergeTests<T extends readonly unknown[]>(...tests: T)

Runtime behavior:

Iteratively extends left -> right.

Later fixtures override earlier ones (last-writer-wins)

Works for arbitrarily long argument lists.

Type behavior:

MergeTestContexts<T> iterates over the tuple and intersects all extracted contexts.

Variadic inference is preserved even for nested merges.

This is now covered by runtime and type tests (including 3+ merges)

): TestAPI<A & B> {
const ctx = getChainableContext(testB)
if (!ctx) {
throw new TypeError('Cannot merge tests: extension is not a valid TestAPI')
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 a bit cryptic, what is a TestAPI to the user? Maybe just mention that this function requires an extended test

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed exposing internal terminology like "TestAPI" in runtime errors isn’t ideal

I updated the error message to:

"mergeTests requires extended test instances created via test.extend()"

This makes the requirement clearer without leaking internal implementation concepts

beforeEach,
describe,
it,
mergeTests,
Copy link
Member

Choose a reason for hiding this comment

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

This function needs to be documented

@Ujjwaljain16
Copy link
Contributor Author

@sheremet-va
I’d really appreciate your guidance on a few architectural decisions here, especially around typing and fixture composition.

  1. Strict Type Safety & Generic Constraints

While implementing mergeTests, I initially tried constraining inputs as:

T extends readonly TestAPI<any>[]

However this fails because TestAPI is invariant in its generic parameter due to the TestCollectorCallable definition:

<ExtraContext extends C>(...)

This makes:

TestAPI<{ a: number }>

not assignable to:

TestAPI<any>

So constraining to TestAPI<any>[] rejects valid usage.

To work around this, I relaxed the constraint to:

T extends readonly unknown[]

and enforced type validity structurally via ExtractTestContext<T>.

This preserves full context inference for consumers while avoiding the invariance trap.

My question:

Would you prefer a stricter nominal constraint even if it requires internal casts, or is structural validation acceptable here given the variance characteristics of TestAPI?


  1. Context Extraction Strategy

I initially attempted:

T extends TestAPI<infer C> ? C : never

But this proved unreliable because TestAPI is a complex intersection type with overloaded call signatures. In many cases TypeScript collapsed inference to unknown or never

Instead, I switched to structural extraction based on beforeEach, since it is the canonical API that consumes the contextual type:

T extends { beforeEach: (fn: infer F, ...args: any) => any }
  ? F extends (context: infer C, ...args: any) => any
    ? C
    : never
  : never

This also correctly accounts for the actual listener signature:

(context, suite)

Structurally matching on beforeEach felt more stable than relying on TestAPI<infer C>.

Would you prefer:

Structural extraction (as implemented), or
A nominal extraction approach even if it’s slightly less inference-friendly?


  1. Fixture Merging Strategy

I explored implementing a low-level merge() directly on TestFixtures.

However, that would:

Bypass the .extend() validation pipeline
Duplicate dependency/scope validation logic
Risk drifting from canonical fixture behavior

Instead, I:

  1. Added toUserFixtures() to serialize definition-time registrations
  2. Re-applied them using .extend()

This keeps all validation inside the existing extension mechanism and preserves immutability.

Would you consider this acceptable alignment with Vitest’s design, or would you prefer fixture merging to happen at the TestFixtures level instead?

I’m happy to adjust direction based on your preference

@sheremet-va
Copy link
Member

sheremet-va commented Feb 16, 2026

Would you prefer a stricter nominal constraint even if it requires internal casts, or is structural validation acceptable here given the variance characteristics of TestAPI?

Casting types internally is fine to me as long as public API is strict

Structural extraction (as implemented), or A nominal extraction approach even if it’s slightly less inference-friendly?

I don't have a preference. As long as public API works correctly, any trick is good to me

Bypass the .extend() validation pipeline
Duplicate dependency/scope validation logic

Why would it do that? Didn't we already validated everything in the original test.extend calls? We are just merging them afterwards, no?

Could we abstract it in a better way, so it's more reusable without extra loops?

Risk drifting from canonical fixture behavior

I don't know what that means

@sheremet-va
Copy link
Member

sheremet-va commented Feb 16, 2026

To work around this, I relaxed the constraint to:

I think one of the possible solutions here is to define a lot of overloads of mergeTests:

function mergeTests<A, B, C>(test1: TestAPI<A>, test2: TestAPI<B>, test3: TestAPI<C>): TestAPI<A & B & C>
function mergeTests<A, B>(test1: TestAPI<A>, test2: TestAPI<B>): TestAPI<A & B>
// ... and so on

@netlify
Copy link

netlify bot commented Feb 16, 2026

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit a526ad8
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/699348ba9f31d400082c2e88
😎 Deploy Preview https://deploy-preview-9662--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@Ujjwaljain16 Ujjwaljain16 force-pushed the feat/merge-tests branch 2 times, most recently from 4902540 to d8e6beb Compare February 16, 2026 17:33
@netlify
Copy link

netlify bot commented Feb 16, 2026

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 000fec2
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/699604424bd90700088edd1b
😎 Deploy Preview https://deploy-preview-9662--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

- Refactor mergeTests to use explicit nominal overloads (up to 6 args)
- Remove complex structural extraction types (ExtractTestContext, UnionToIntersection)
- Implement context-aware fixture merging in TestFixtures.merge
- Add comprehensive runtime and type tests for mergeTests
- Ensure strictly nominal TestAPI propagation
@Ujjwaljain16 Ujjwaljain16 marked this pull request as draft February 17, 2026 02:07
@Ujjwaljain16 Ujjwaljain16 marked this pull request as ready for review February 17, 2026 17:37
Copy link
Member

@sheremet-va sheremet-va left a comment

Choose a reason for hiding this comment

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

It's pretty hard to review

The previous implementation is clean and easy to follow, the new one removes all the helpful comments and introduces concepts and code that's just hard to follow

There are registrations and overrides. registrations are scoped to the last test.extend, and overrides are applied to tests in specified suites, that's it. What this PR adds are some kind of pure registrations, and keeps track of parents and ancestors (why? we have registrations already - overridden suites also include all fixtures already)

@Ujjwaljain16
Copy link
Contributor Author

Ujjwaljain16 commented Feb 18, 2026

Appreciate the push to simplify you were right about the overhead. I've refactored the implementation back to the baseline 'flat model' and moved to direct Map-merging in extend()
It's much cleaner now and avoids the conversion loop entirely.
I also restored all original documentation and synchronized the internal types with main to ensure there's no drift. Everything is verified and synced with upstream

Copy link
Member

@sheremet-va sheremet-va left a comment

Choose a reason for hiding this comment

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

I don't think there is enough tests. Unit tests are not enough, we need a comprehensive test suite similar to test/cli/test/scoped-fixtures.test.ts

What happens here, for example?

const t1 = test.extend('f', { scope: 'file' }, () => 'file')
const t2 = test.extend('f', { scope: 'test', () => 'test')
mergeTests(t1, t2)

This should fail properly, for example

There are also no tests that have scope, injected, or even a function fixture (() => something)

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

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

// 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

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement a way to merge test contexts

3 participants