Skip to content

feat(vitest): create vi.when()#10174

Merged
sheremet-va merged 42 commits into
vitest-dev:mainfrom
macarie:feat/vi-when
Jun 18, 2026
Merged

feat(vitest): create vi.when()#10174
sheremet-va merged 42 commits into
vitest-dev:mainfrom
macarie:feat/vi-when

Conversation

@macarie

@macarie macarie commented Apr 22, 2026

Copy link
Copy Markdown
Member

Description

Resolves #756

Todo:

  • Check types and code
  • Solve @todos
  • Write documentation
  • Add more tests

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. If the feature is substantial or introduces breaking changes without a discussion, PR might be closed.
  • Ideally, include a test that fails without this PR but passes with it.
  • Please, don't make changes to pnpm-lock.yaml unless you introduce a new test example.
  • Please check Allow edits by maintainers to make review process faster. Note that this option is not available for repositories that are owned by Github organizations.

Tests

  • Run the tests with pnpm test:ci.

Documentation

  • If you introduce new functionality, document it. You can run documentation with pnpm run docs command.

Changesets

  • Changes in changelog are generated from PR name. Please, make sure that it explains your changes in an understandable manner. Please, prefix changeset messages with feat:, fix:, perf:, docs:, or chore:.

@netlify

netlify Bot commented Apr 22, 2026

Copy link
Copy Markdown

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 800b2ce
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6a33a21475b0a800084746ba
😎 Deploy Preview https://deploy-preview-10174--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

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

@macarie
macarie force-pushed the feat/vi-when branch 2 times, most recently from 607d2d3 to 96e8997 Compare April 27, 2026 10:20
/**
* Defines behavior for a specific set of arguments.
*
* Multiple behaviors can be stacked for the same arguments. They are matched last-registered first (LIFO stack, last in first out), so earlier entries act as fallbacks once later ones are exhausted.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should this be LIFO or FIFO? 👀

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The doc example doesn't read well to me, so I want FIFO.

   * @example
   * // Stack behaviors: first call returns `false`, subsequent calls return `true`
   * vi.when(spy)
   *   .calledWith('darkMode')
   *   .thenReturn(true)
   *   .thenReturnOnce(false)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

So here you would consider thenReturnOnce to be unreachable?

You'd write it like this?

vi.when(spy)
  .calledWith('darkMode')
  .thenReturnOnce(false)
  .thenReturn(true)

I wonder what others think of this, as the fluent/builder APIs I've used were all LIFO-style, but the other mock APIs do seem to be FIFO.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not sure, but from my limited english interpretation, the API suggest it should naturally read like:

when spy is called with x, then return y once, then return z (forever).

vi.when(spy)
  .calledWith(x)
  .thenReturnOnce(y)
  .thenReturn(z)

so current order seems inverted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is to be decided either by internal or public pole.

Comment thread packages/vitest/src/integrations/mock/when.ts Outdated
Comment thread packages/expect/src/jest-expect.ts Outdated
Comment thread packages/vitest/src/integrations/mock/when.ts

@macarie macarie left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I have a small recursive package issue... I think when should live in packages/spy, but it needs equals from pacakges/expect, which depends on packages/spy...

Where would it make the most sense to have when? Because right now expect can't use the helper function as it lives in pacakges/vitest 🙃

Comment thread packages/expect/src/index.ts Outdated
export { JestChaiExpect } from './jest-expect'
export { JestExtend } from './jest-extend'
export { addCustomEqualityTesters } from './jest-matcher-utils'
export { addCustomEqualityTesters, getCustomEqualityTesters /* @todo */ } from './jest-matcher-utils'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is there a function that already retrieves the custom matchers and uses it?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can just move it to utils/equals

@macarie
macarie marked this pull request as ready for review May 11, 2026 14:17
sheremet-va
sheremet-va previously approved these changes May 11, 2026

@sheremet-va sheremet-va left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM! Just some nitpicks

Comment thread docs/api/vi.md Outdated
IntersectionObserver === undefined
```

### vi.when <Version type="experimental">5.0</Version> <Experimental /> {#vi-when}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it doesn't need to be experimental in v5

* // calledWith(2)
* // ✗ thenReturn(42) never called
*/
'~getDiagnostics': () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can use _. The reason why ~ is usually chosen is because it's the last in the list of public APIs, but we have @internal flag which removes this method from the bundle, its types are only available in this repo

}
}

function isExhausted(action: Behavior<unknown[], unknown>['actions'][number]): boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we just have a separate type instead of doing archeology in Behavior<unknown[], unknown>['actions'][number]?

Comment thread test/unit/test/mocking/vi-when.test.ts Outdated

expect(d.pendingBehaviors).toMatchInlineSnapshot(`
"calledWith("a", 0)
✗ thenReturn(97, { times: 2 }) 2 remaining (of 2)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't it be 2 remaining (out of 2)?

Comment thread docs/api/vi.md

Defines per-argument behaviors on a spy, replacing its implementation for the duration of the `when` chain.

Call `.calledWith(...args)` on the returned object to specify which call arguments to match, then chain one or more `then*` methods to declare what the spy should return, throw, or resolve when invoked with those arguments. Arguments are matched with deep equality and support asymmetric matchers such as `expect.any()`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What's the behavior of hanging calledWith without thenXxx? For example, I'm wondering if it's expected use case to only use calledWith to combine with toHaveBeenExhausted e.g.

const spy = vi.fn()
const w = vi.when(spy).calledWith(1)
expect(w).not.toHaveBeenExhausted()
spy(1)
expect(w).toHaveBeenExhausted()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It will use the provided fallback action/function. By default it would call the original spy implementation (which in this case is a noop).

There's indeed a bug with this, as the assertion would work even if it shouldn't. I wonder if it still makes sense for toHaveBeenExhausted to throw when there are no registered actions, as the check would be a bit more complex (checking that at least one behavior has an action) 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think throwing if there are no registered behaviours makes sense

/**
* Defines behavior for a specific set of arguments.
*
* Multiple behaviors can be stacked for the same arguments. They are matched last-registered first (LIFO stack, last in first out), so earlier entries act as fallbacks once later ones are exhausted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The doc example doesn't read well to me, so I want FIFO.

   * @example
   * // Stack behaviors: first call returns `false`, subsequent calls return `true`
   * vi.when(spy)
   *   .calledWith('darkMode')
   *   .thenReturn(true)
   *   .thenReturnOnce(false)

const diagnostics = w._getDiagnostics()

if (diagnostics.isExhausted === isNot) {
throw new chai.AssertionError(isNot

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think you even need to care about negate if you use this.assert here instead of throwing an error

@macarie

macarie commented May 14, 2026

Copy link
Copy Markdown
Member Author

I think I fixed all the review comments. I fixed another edge-case I found with Hiroshi's incomplete chain example and added a couple of test cases, so hopefully the suite has enough coverage and will be able to catch regressions in the future.

@sheremet-va

Copy link
Copy Markdown
Member

It would be nice to have a new recipe now after the #10226 was merged. See #10381 for follow up, and https://main.vitest.dev/guide/recipes/db-transaction.html for example

Just a page explaining the use case of this feature

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I have to triple check this tomorrow (examples were generated, have to check for API hallucinations).

I'm not even sure if it's a recipe or just a guide at this point, but I like the structure and that it's less abstract than what I've added previously.

Comment thread docs/guide/recipes/conditional-mocking.md Outdated
Comment thread docs/guide/recipes/conditional-mocking.md Outdated
sheremet-va
sheremet-va previously approved these changes May 25, 2026

@sheremet-va sheremet-va left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

Comment thread docs/api/vi.md Outdated
IntersectionObserver === undefined
```

### vi.when <Version>5.0</Version> {#vi-when}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
### vi.when <Version>5.0</Version> {#vi-when}
### vi.when <Version>5.0.0</Version> {#vi-when}


# Conditional Mocking with `vi.when`

<Version>5.0.0</Version>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
<Version>5.0.0</Version>

I don't think we need the version here

})
```

This works, but it becomes tedious because you have to write the argument-matching logic yourself. This is something that Vitest can handle for you when using the [`vi.when`](/api/vi#vi-when) API.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
This works, but it becomes tedious because you have to write the argument-matching logic yourself. This is something that Vitest can handle for you when using the [`vi.when`](/api/vi#vi-when) API.
This works, but it becomes tedious because you have to write the argument-matching logic yourself. This is something that Vitest can handle for you when using the [`vi.when`](/api/vi#vi-when) <Version>5.0.0</Version> API.

title: Conditional Mocking with vi.when | Recipes
---

# Conditional Mocking with `vi.when`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would be nice to also mention vi.when in "mocking functions" and just link here in the Learn section too (don't explain the API there for the sake of simplicity, just have a link at the bottom, I think - or a small mention in ::: tip)

@sheremet-va
sheremet-va merged commit 3900e63 into vitest-dev:main Jun 18, 2026
15 of 18 checks passed
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.

[Feature Request] Extend vi.fn

3 participants