diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index c42839e96..b608d7b55 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -1047,6 +1047,10 @@ export default ({ mode }: { mode: string }) => {
text: 'Auto-Cleanup with `using`',
link: '/guide/recipes/explicit-resources',
},
+ {
+ text: 'Conditional Mocking with `vi.when`',
+ link: '/guide/recipes/conditional-mocking',
+ },
{
text: 'Per-File Isolation Settings',
link: '/guide/recipes/disable-isolation',
diff --git a/docs/api/expect.md b/docs/api/expect.md
index d2603aa80..ae98bdddf 100644
--- a/docs/api/expect.md
+++ b/docs/api/expect.md
@@ -1453,6 +1453,38 @@ test('spy function returns bananas on second call', async () => {
})
```
+## toHaveBeenExhausted 5.0.0 {#tohavebeenexhausted}
+
+- **Type:** `() => void`
+
+This assertion checks that every behavior registered on a [`vi.when`](/api/vi#vi-when) chain has been consumed. A behavior is considered exhausted when it has been called the number of times specified by its `times` option, or at least once for behaviors that apply indefinitely.
+
+Requires a `When` chain returned by `vi.when` to be passed to `expect`.
+
+```ts
+import { expect, test, vi } from 'vitest'
+
+test('all behaviors were consumed', () => {
+ const spy = vi.fn()
+ const w = vi.when(spy)
+ .calledWith(1)
+ .thenReturnOnce('once')
+ .calledWith(2)
+ .thenReturn('always')
+
+ expect(w).not.toHaveBeenExhausted()
+
+ spy(1) // consumes the `thenReturnOnce` behavior
+ spy(2) // satisfies the `thenReturn` behavior (called at least once)
+
+ expect(w).toHaveBeenExhausted()
+})
+```
+
+::: warning
+A `When` chain with no registered behaviors is never considered exhausted. `toHaveBeenExhausted` only passes when at least one `calledWith` with an associated action (`then*`) has been registered and every registered behavior has been fully consumed.
+:::
+
## called 4.1.0 {#called}
- **Type:** `Assertion` (property, not a method)
diff --git a/docs/api/vi.md b/docs/api/vi.md
index 656a199a1..3d84ea781 100644
--- a/docs/api/vi.md
+++ b/docs/api/vi.md
@@ -797,6 +797,134 @@ globalThis.IntersectionObserver === undefined
IntersectionObserver === undefined
```
+### vi.when 5.0.0 {#vi-when}
+
+```ts
+interface WhenOptions {
+ onUnmatched?: 'throw' | 'passthrough' | ((...args: unknown[]) => unknown)
+}
+
+interface BehaviorOptions {
+ times?: number
+}
+
+function when(spy: Mock, options?: WhenOptions): When
+```
+
+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()`.
+
+```ts
+const spy = vi.fn()
+
+vi.when(spy)
+ .calledWith(1)
+ .thenReturn('one')
+ .calledWith(2)
+ .thenReturn('two')
+
+expect(spy(1)).toBe('one')
+expect(spy(2)).toBe('two')
+```
+
+Available `then*` methods:
+
+| Method | Description |
+|--------|-------------|
+| `thenReturn(value, options?)` | Returns `value`. |
+| `thenReturnOnce(value)` | Returns `value` once, then falls back. |
+| `thenThrow(error, options?)` | Throws `error`. |
+| `thenThrowOnce(error)` | Throws `error` once, then falls back. |
+| `thenResolve(value, options?)` | Returns a resolved `Promise` with `value`. |
+| `thenResolveOnce(value)` | Resolves once, then falls back. |
+| `thenReject(error, options?)` | Returns a rejected `Promise` with `error`. |
+| `thenRejectOnce(error)` | Rejects once, then falls back. |
+
+The optional `times` option limits how many times a behavior applies before being exhausted. Behaviors registered for the same arguments are consumed last-in-first-out: the most recently registered behavior is tried first, and once exhausted, earlier ones act as fallbacks.
+
+```ts
+const spy = vi.fn<(key: string) => string>()
+
+vi.when(spy)
+ .calledWith('theme')
+ .thenReturn('light') // fallback, applies indefinitely
+ .thenReturn('dark', { times: 2 }) // applied first for the next 2 calls
+
+expect(spy('theme')).toBe('dark')
+expect(spy('theme')).toBe('dark')
+expect(spy('theme')).toBe('light') // falls back
+```
+
+When called with arguments that match no registered behavior, the spy falls through to its original implementation by default. Use the `onUnmatched` option to change this:
+
+- `'passthrough'` (**default**): delegates to the spy's original implementation
+- `'throw'`: throws an error listing the unmatched arguments
+- a function: called with the unmatched arguments; its return value is used
+
+```ts
+const spy = vi.fn<(id: number) => string>()
+
+vi.when(spy, { onUnmatched: 'throw' })
+ .calledWith(1)
+ .thenReturn('Alice')
+
+expect(spy(1)).toBe('Alice')
+expect(() => spy(99)).toThrow() // no behavior defined for 99
+```
+
+The `When` object returned by `vi.when` supports the [`toHaveBeenExhausted` assertion](/api/expect#tohavebeenexhausted), which passes once every registered behavior has been consumed.
+
+```ts
+const spy = vi.fn()
+const w = vi.when(spy)
+ .calledWith(1)
+ .thenReturnOnce('once')
+ .calledWith(2)
+ .thenReturn('always')
+
+expect(w).not.toHaveBeenExhausted()
+
+spy(1) // consumes the `thenReturnOnce` behavior
+spy(2) // satisfies `thenReturn` (called at least once)
+
+expect(w).toHaveBeenExhausted()
+```
+
+::: tip
+In environments that support [Explicit Resource Management](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management), you can use `using` instead of `const` to automatically restore the spy's original implementation when the containing block exits:
+
+```ts
+const spy = vi.fn(() => 'original')
+
+{
+ using w = vi.when(spy)
+ .calledWith('hello')
+ .thenReturn('mocked')
+
+ expect(spy('hello')).toBe('mocked')
+} // ← spy's original implementation is restored here
+
+expect(spy('hello')).toBe('original')
+```
+:::
+
+### vi.isWhenChain 5.0.0 {#vi-iswhenchain}
+
+```ts
+function isWhenChain(input: object): input is When
+```
+
+Returns `true` if the given value is a `When` chain created by [`vi.when`](#vi-when). If you are using TypeScript, it will also narrow down its type.
+
+```ts
+const spy = vi.fn()
+const w = vi.when(spy).calledWith(1).thenReturn(0)
+
+expect(vi.isWhenChain(w)).toBe(true)
+expect(vi.isWhenChain(spy)).toBe(false)
+```
+
## Fake Timers
This sections describes how to work with [fake timers](/guide/mocking/timers).
diff --git a/docs/guide/learn/mock-functions.md b/docs/guide/learn/mock-functions.md
index 2892ff7b2..bc20ad135 100644
--- a/docs/guide/learn/mock-functions.md
+++ b/docs/guide/learn/mock-functions.md
@@ -72,6 +72,10 @@ test('mock async return values', async () => {
})
```
+::: tip
+`mockReturnValue` always returns the same value regardless of the arguments the mock receives. If you need argument-specific return values, [`vi.when`](/api/vi#vi-when) lets you attach different behaviors for different argument combinations without writing your own `if/else` logic. See the [Conditional Mocking](/guide/recipes/conditional-mocking) recipe for details.
+:::
+
## Mock Implementation
Sometimes you need more than a fixed return value. You want the mock to actually do something with its arguments. [`mockImplementation`](/api/mock#mockimplementation) lets you provide a full replacement function:
diff --git a/docs/guide/mocking/functions.md b/docs/guide/mocking/functions.md
index 58c729911..a2530cb8f 100644
--- a/docs/guide/mocking/functions.md
+++ b/docs/guide/mocking/functions.md
@@ -8,6 +8,10 @@ If you need to pass down a custom function implementation as an argument or crea
Both `vi.spyOn` and `vi.fn` share the same methods.
+::: tip
+If you need a mock to return different values depending on the arguments it receives, [`vi.when()`](/api/vi#vi-when) lets you define argument-specific behaviors without writing your own `if/else` logic. See the [Conditional Mocking](/guide/recipes/conditional-mocking) recipe for details.
+:::
+
## Example
```js
diff --git a/docs/guide/recipes/conditional-mocking.md b/docs/guide/recipes/conditional-mocking.md
new file mode 100644
index 000000000..0fb8e190d
--- /dev/null
+++ b/docs/guide/recipes/conditional-mocking.md
@@ -0,0 +1,270 @@
+---
+title: Conditional Mocking with vi.when | Recipes
+---
+
+# Conditional Mocking with `vi.when`
+
+::: tip Prerequisites
+This recipe assumes you already have some familiarity with [mocking](/guide/mocking) in Vitest.
+:::
+
+When a mock needs to return different values depending on the arguments it receives, [`mockReturnValue`](/api/mock#mockreturnvalue) doesn't help because it always returns the same value. The standard approach would be to use [`mockImplementation`](/api/mock#mockimplementation) with a `switch` or a series of `if/else` statements:
+
+```ts
+db.findById.mockImplementation((id) => {
+ if (id === 1) {
+ return Promise.resolve({ id: 1, name: 'Ella' })
+ }
+
+ if (id === 2) {
+ return Promise.resolve({ id: 2, name: 'Gracie' })
+ }
+
+ return Promise.resolve(undefined)
+})
+```
+
+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) 5.0.0 API.
+
+## Pattern
+
+`vi.when` takes a spy and lets you define argument-specific behaviors.
+
+Call `.calledWith(...args)` to declare which arguments to match. This creates a _behavior_.
+
+Then attach an _action_ by calling a `then*` method. The action determines what happens when the behavior matches.
+
+Multiple behaviors can be chained on the same spy:
+
+```ts
+import { test, vi } from 'vitest'
+import { getUserById } from './user.ts'
+
+test('returns user data', async () => {
+ const db = { findById: vi.fn() }
+
+ vi.when(db.findById)
+ .calledWith(1)
+ .thenResolve({ id: 1, name: 'Ella' })
+ .calledWith(2)
+ .thenResolve({ id: 2, name: 'Gracie' })
+
+ await expect(getUserById(db, 1)).resolves.toEqual({ name: 'Ella' })
+ await expect(getUserById(db, 2)).resolves.toEqual({ name: 'Gracie' })
+})
+```
+
+The same approach works across all mock outcome types. Here is the full set of actions and their equivalents:
+
+| Action | Equivalent to | Equivalent code |
+|---|---|---|
+| `thenReturn(value)` | `mockReturnValue(value)` | `return value` |
+| `thenThrow(error)` | `mockThrow(error)` | `throw error` |
+| `thenResolve(value)` | `mockResolvedValue(value)` | `return Promise.resolve(value)` |
+| `thenReject(error)` | `mockRejectedValue(error)` | `return Promise.reject(error)` |
+
+## Stacking actions
+
+A single behavior can have multiple actions attached to it. When the behavior matches, actions are _consumed_ in **last-in-first-out** order: the most recently registered action runs first. Once that action has been consumed, Vitest falls back to the previous one. Use the `times` option to limit how many calls an action handles before falling through to the next action. An action with no `times` limit runs indefinitely.
+
+Because actions are evaluated in reverse registration order, indefinite actions should be registered first so that later finite actions can temporarily override them.
+
+```ts
+import { test, vi } from 'vitest'
+import { readConfig } from './config.ts'
+
+test('retries after an initial failure', async () => {
+ const fetchInstance = vi.fn<() => Promise>()
+
+ vi.when(fetchInstance)
+ .calledWith('/data/config.json')
+ .thenResolve(new Response('{ debug: true }'))
+ // ↳ indefinite fallback
+ .thenReject(new Error('network error'), { times: 1 })
+ // ↳ applied first and consumed after one call
+
+ await expect(readConfig(fetchInstance)).resolves.toEqual({ debug: true })
+
+ expect(fetchInstance).toHaveBeenCalledTimes(2)
+})
+```
+
+For convenience, `then*Once` shorthands are available and equivalent to `{ times: 1 }`: `thenReturnOnce`, `thenResolveOnce`, `thenThrowOnce`, `thenRejectOnce`.
+
+## Asymmetric matchers
+
+`calledWith` supports [asymmetric matchers](/guide/learn/matchers#asymmetric-matchers). This is useful when you care about the shape or type of an argument rather than its exact value:
+
+```ts
+test('sends email to each recipient', () => {
+ vi.when(sendEmail)
+ .calledWith(expect.stringContaining('@'))
+ .thenReturn({ ok: true, message: 'sent via external relay' })
+})
+```
+
+Behaviors, unlike actions, are matched in **first-in-first-out** order. The first behavior whose arguments match the call wins, just like a chain of `if/else` statements. Specific matchers must therefore be registered before broad ones.
+
+```ts
+test('sends email to each recipient', () => {
+ vi.when(sendEmail)
+ .calledWith(expect.stringContaining('@internal.example.com'))
+ .thenReturn({ ok: true, message: 'sent via internal relay' })
+ .calledWith(expect.stringContaining('@'))
+ .thenReturn({ ok: true, message: 'sent via external relay' })
+})
+```
+
+::: warning Behavior Merging
+When registering a new behavior, Vitest checks existing behaviors in registration order. If the new arguments already match an existing behavior, the new action is merged into that behavior instead of creating a new one.
+
+This is especially important with broad asymmetric matchers:
+
+```ts
+vi.when(getRole)
+ .calledWith(expect.any(String))
+ .thenReturn('user')
+ .calledWith('admin@example.com')
+ .thenReturnOnce('admin')
+```
+
+Because the second registration is merged into the existing behavior, the `'admin'` action is not scoped to `'admin@example.com'`. Instead, it becomes the next action for the entire `expect.any(String)` behavior. The resulting behavior acts as if it had been written like this:
+
+```ts
+vi.when(getRole)
+ .calledWith(expect.any(String))
+ .thenReturn('user')
+ .thenReturnOnce('admin')
+```
+
+As a result, the first call with any string returns `'admin'`, while later calls return `'user'`:
+
+```ts
+expect(getRole('user@example.com')).toBe('admin')
+expect(getRole('user@example.com')).toBe('user')
+```
+:::
+
+## Handling unmatched calls
+
+By default, when the spy is called with arguments that match no registered behavior, it falls back to the spy's original implementation. If the spy has no original implementation, it returns `undefined`.
+
+There are three ways to handle this differently:
+
+1. [throwing an error](#onunmatched-throw);
+1. [running a custom function](#onunmatched-fn);
+1. [using asymmetric matchers as catch-all behaviors](#asymmetric-matcher-as-catch-all).
+
+### `onUnmatched: 'throw'`
+
+Pass `{ onUnmatched: 'throw' }` to throw whenever the spy is called with unregistered arguments:
+
+```ts
+vi.when(db.findById, { onUnmatched: 'throw' })
+ .calledWith(1)
+ .thenResolve({ id: 1, name: 'Ella' })
+
+await expect(db.findById(1)).resolves.toMatchObject({ name: 'Ella' })
+await expect(db.findById(3)).rejects.toThrow(
+ 'vi.when: no behavior defined when called with [3]',
+)
+```
+
+The error message includes the unmatched arguments. The error type and message are fixed and cannot be customized.
+
+### `onUnmatched: fn`
+
+Pass a function to handle unmatched calls with custom logic, for example when a shared mock needs a different fallback per test.
+
+```ts
+const db = { findById: vi.fn() }
+
+test('returns a placeholder for unknown ids', async () => {
+ vi.when(
+ db.findById,
+ { onUnmatched: id => Promise.resolve({ id, name: `User ${id}` }) }
+ )
+ .calledWith(1)
+ .thenResolve({ id: 1, name: 'Ella' })
+
+ await expect(db.findById(1)).resolves.toMatchObject({ name: 'Ella' })
+ await expect(db.findById(42)).resolves.toMatchObject({ name: 'User 42' })
+})
+```
+
+The function is called with the same arguments as the spy and its return value is used directly as the spy's result. If it throws or returns a rejected promise, that error propagates to the caller just as it would from any action.
+
+### Asymmetric matcher as catch-all
+
+Registering a broad `calledWith` last acts as a fallback for calls that do not match any earlier, more specific behavior. The fallback behavior can return a specific value, resolve or reject a promise, or throw a typed error.
+
+```ts
+vi.when(db.findById)
+ .calledWith(1)
+ .thenResolve({ id: 1, name: 'Ella' })
+ .calledWith(2)
+ .thenResolve({ id: 2, name: 'Gracie' })
+ .calledWith(expect.any(Number))
+ .thenReject(new Error('user not found'))
+```
+
+## Asserting that all behaviors were called
+
+To check that all registered behaviors were actually matched and their actions consumed, the object returned by `vi.when` supports the [`toHaveBeenExhausted`](/api/expect#tohavebeenexhausted) assertion:
+
+```ts
+test('loads both users', async () => {
+ const db = { findById: vi.fn() }
+
+ const w = vi.when(db.findById)
+ .calledWith(1)
+ .thenResolveOnce({ id: 1, name: 'Ella' })
+ .calledWith(2)
+ .thenResolveOnce({ id: 2, name: 'Gracie' })
+
+ await loadDashboard(db)
+
+ expect(w).toHaveBeenExhausted()
+})
+```
+
+In this example, if `loadDashboard` only calls `findById(1)`, the test fails with a message listing the behaviors that were never matched:
+
+```
+AssertionError: expected all behaviors to have been exhausted, but some remain:
+
+ calledWith(2)
+ ✗ thenReturn({ id: 2, name: 'Gracie' }) never called
+```
+
+::: warning Caveat
+A `vi.when` chain with no behaviors is never considered exhausted. The same applies to a bare `.calledWith()` with no `then*` action attached. Both will always cause `toHaveBeenExhausted` to fail.
+
+Indefinite actions (no `times` limit) satisfy exhaustion checks after being used at least once. The actions keep responding after that, but the assertion is satisfied.
+:::
+
+## Automatic cleanup with `using`
+
+`vi.when` supports the [Explicit Resource Management](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management) protocol.
+
+Declare the chain with `using` to scope behaviors to the current block and restore the spy automatically when execution leaves it.
+
+```ts
+const spy = vi.fn(() => 'original')
+
+test('with mocked behavior', () => {
+ using w = vi.when(spy).calledWith('hello').thenReturn('mocked')
+ expect(spy('hello')).toBe('mocked')
+}) // ← restored here
+
+test('without mocked behavior', () => {
+ expect(spy('hello')).toBe('original')
+})
+```
+
+## See also
+
+- [`vi.when`](/api/vi#vi-when)
+- [`toHaveBeenExhausted`](/api/expect#tohavebeenexhausted)
+- [`vi.isWhenChain`](/api/vi#vi-iswhenchain)
+- [Auto-Cleanup with `using`](/guide/recipes/explicit-resources)
diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts
index ce2f5acdb..d2a5bf9fe 100644
--- a/packages/expect/src/index.ts
+++ b/packages/expect/src/index.ts
@@ -20,7 +20,7 @@ export {
} from './jest-asymmetric-matchers'
export { JestChaiExpect } from './jest-expect'
export { JestExtend } from './jest-extend'
-export { addCustomEqualityTesters } from './jest-matcher-utils'
+export { addCustomEqualityTesters, getCustomEqualityTesters } from './jest-matcher-utils'
export * from './jest-utils'
export { getState, setState } from './state'
export * from './types'
diff --git a/packages/vitest/src/integrations/chai/setup.ts b/packages/vitest/src/integrations/chai/setup.ts
index 3b949ca70..e5e293937 100644
--- a/packages/vitest/src/integrations/chai/setup.ts
+++ b/packages/vitest/src/integrations/chai/setup.ts
@@ -5,6 +5,7 @@ import {
JestChaiExpect,
JestExtend,
} from '@vitest/expect'
+import { MockPlugin } from '../mock/chai'
import { SnapshotPlugin } from '../snapshot/chai'
chai.use(JestExtend)
@@ -12,3 +13,4 @@ chai.use(JestChaiExpect)
chai.use(ChaiStyleAssertions)
chai.use(SnapshotPlugin)
chai.use(JestAsymmetricMatchers)
+chai.use(MockPlugin)
diff --git a/packages/vitest/src/integrations/mock/chai.ts b/packages/vitest/src/integrations/mock/chai.ts
new file mode 100644
index 000000000..02ec613d3
--- /dev/null
+++ b/packages/vitest/src/integrations/mock/chai.ts
@@ -0,0 +1,27 @@
+import type { ChaiPlugin } from '@vitest/expect'
+import { wrapAssertion } from '@vitest/expect'
+import { isWhenChain } from './when'
+
+export const MockPlugin: ChaiPlugin = (chai, utils) => {
+ utils.addMethod(
+ chai.Assertion.prototype,
+ 'toHaveBeenExhausted',
+ wrapAssertion(utils, 'toHaveBeenExhausted', function (this) {
+ const chain = utils.flag(this, 'object')
+
+ if (!isWhenChain(chain)) {
+ throw new TypeError(
+ `${utils.inspect(chain)} is not a \`vi.when\` instance`,
+ )
+ }
+
+ const diagnostics = chain._getDiagnostics()
+
+ this.assert(
+ diagnostics.isExhausted,
+ `expected all behaviors to have been exhausted, but some remain:\n\n ${diagnostics.pendingBehaviors.replaceAll(/\n(?!\n)/g, '\n ')}`,
+ 'expected at least one behavior to remain un-exhausted, but all were',
+ )
+ }),
+ )
+}
diff --git a/packages/vitest/src/integrations/mock/when.ts b/packages/vitest/src/integrations/mock/when.ts
new file mode 100644
index 000000000..d1a505da7
--- /dev/null
+++ b/packages/vitest/src/integrations/mock/when.ts
@@ -0,0 +1,549 @@
+import type { Mock, Procedure } from '@vitest/spy'
+import type { Disposable } from 'vitest/optional-runtime-types.js'
+import { equals, getCustomEqualityTesters, iterableEquality } from '@vitest/expect'
+import { isMockFunction } from '@vitest/spy'
+import { stringify } from '@vitest/utils/display'
+import { noop } from '@vitest/utils/helpers'
+
+type BehaviorType = 'return' | 'throw' | 'resolve' | 'reject'
+
+const whenSymbol = Symbol.for('$$vitest:when')
+
+/**
+ * Returns `true` if the given value is a {@linkcode When} chain created by {@linkcode when|vi.when}.
+ *
+ * @param input - The value to check.
+ * @returns `true` if `input` is a {@linkcode When} instance, `false` otherwise.
+ *
+ * @example
+ * const spy = vi.fn()
+ * const w = vi.when(spy).calledWith(1).thenReturn(0)
+ *
+ * expect(isWhenChain(w)).toBe(true)
+ * expect(isWhenChain(spy)).toBe(false)
+ */
+export function isWhenChain(input: object): input is When {
+ try {
+ return Reflect.has(input, whenSymbol)
+ }
+ catch {
+ return false
+ }
+}
+
+interface BehaviorAction {
+ type: BehaviorType
+ value: Value | unknown
+ times: number
+ remaining: number
+ called: boolean
+}
+
+interface Behavior {
+ arguments: Arguments
+ actions: BehaviorAction[]
+}
+
+interface BehaviorOptions {
+ /**
+ * How many times this behavior should apply before being exhausted.
+ *
+ * By default it applies indefinitely.
+ *
+ * @default Number.POSITIVE_INFINITY
+ */
+ times?: number | undefined
+}
+
+type OnceBehaviorOptions = Omit
+
+/**
+ * Fluent interface returned by {@linkcode When.calledWith} for defining behaviors on a specific set of arguments.
+ *
+ * Each `then*` method appends an action and returns the same instance, allowing multiple behaviors to be chained for the same argument set.
+ *
+ * @example
+ * vi.when(spy)
+ * .calledWith('darkMode')
+ * .thenReturn(true)
+ * .thenReturnOnce(false)
+ */
+type CalledWithInstance = When & {
+ /**
+ * Schedules a synchronous return value for when the spy is called with the registered arguments.
+ *
+ * @param value - The value to return.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenReturn: (value: ReturnType, options?: BehaviorOptions | undefined) => CalledWithInstance
+
+ /**
+ * Schedules a resolved `Promise` return value for when the spy is called with the registered arguments.
+ *
+ * @param value - The value to resolve with.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenResolve: (value: ReturnType, options?: BehaviorOptions | undefined) => CalledWithInstance
+
+ /**
+ * Schedules a synchronous return value for a single call with the registered arguments, then removes the behavior.
+ *
+ * @param value - The value to return.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenReturnOnce: (value: ReturnType, options?: OnceBehaviorOptions | undefined) => CalledWithInstance
+
+ /**
+ * Schedules a resolved `Promise` return value for a single call with the registered arguments, then removes the behavior.
+ *
+ * @param value - The value to resolve with.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenResolveOnce: (value: ReturnType, options?: OnceBehaviorOptions | undefined) => CalledWithInstance
+
+ /**
+ * Schedules a thrown error for when the spy is called with the registered arguments.
+ *
+ * @param value - The value to throw.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenThrow: (value: unknown, options?: BehaviorOptions | undefined) => CalledWithInstance
+
+ /**
+ * Schedules a rejected `Promise` for when the spy is called with the registered arguments.
+ *
+ * @param value - The value to reject with.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenReject: (value: unknown, options?: BehaviorOptions | undefined) => CalledWithInstance
+
+ /**
+ * Schedules a thrown error for a single call with the registered arguments, then removes the behavior.
+ *
+ * @param value - The value to throw.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenThrowOnce: (value: unknown, options?: OnceBehaviorOptions | undefined) => CalledWithInstance
+
+ /**
+ * Schedules a rejected `Promise` for a single call with the registered arguments, then removes the behavior.
+ *
+ * @param value - The value to reject with.
+ * @param options - Optional behavior configuration.
+ * @returns The same {@linkcode when|vi.when} instance for chaining.
+ */
+ thenRejectOnce: (value: unknown, options?: OnceBehaviorOptions | undefined) => CalledWithInstance
+}
+
+/**
+ * A handle returned by {@linkcode when|vi.when} that lets define per-argument behaviors on a spy and check whether all defined behaviors have been consumed.
+ *
+ * Implements `Symbol.dispose` so it can be used with the `using` keyword. When the block exits the spy's original implementation is automatically restored.
+ *
+ * @example
+ * using w = vi.when(spy)
+ * .calledWith('hello')
+ * .thenReturn('HELLO')
+ */
+interface When extends Disposable {
+ /**
+ * 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.
+ *
+ * @param args - The arguments to match against.
+ * @returns A {@linkcode CalledWithInstance} for chaining `then*` actions.
+ *
+ * @example
+ * vi.when(spy)
+ * .calledWith(expect.any(Number))
+ * .thenReturn(0)
+ *
+ * @example
+ * // Stack behaviors: first call returns `false`, subsequent calls return `true`
+ * vi.when(spy)
+ * .calledWith('darkMode')
+ * .thenReturn(true)
+ * .thenReturnOnce(false)
+ */
+ calledWith: (...args: Parameters) => CalledWithInstance, Fn>
+ /**
+ * Returns a diagnostic snapshot of the current state of all registered behaviors.
+ *
+ * Useful for producing human-readable failure messages in custom assertions or test helpers.
+ *
+ * @returns An object with:
+ * - `isExhausted` — `true` if every registered behavior has been consumed, `false` otherwise or if no behaviors have been registered.
+ * - `pendingBehaviors` — A formatted multi-line string describing behaviors that have not yet been fully consumed.
+ *
+ * @internal
+ *
+ * @example
+ * const spy = vi.fn()
+ * const w = vi.when(spy)
+ * .calledWith(1).thenReturnOnce(0)
+ * .calledWith(2).thenReturn(42)
+ *
+ * spy(1)
+ *
+ * const { isExhausted, pendingBehaviors } = w.collectPendingBehaviors()
+ *
+ * expect(isExhausted).toBe(false)
+ *
+ * console.log(pendingBehaviors)
+ * // calledWith(2)
+ * // ✗ thenReturn(42) never called
+ */
+ _getDiagnostics: () => {
+ isExhausted: boolean
+ pendingBehaviors: string
+ }
+}
+
+/**
+ * Options for {@linkcode when|vi.when}.
+ */
+interface WhenOptions {
+ /**
+ * Controls what happens when the spy is called with arguments that have no matching `calledWith` behavior.
+ *
+ * Valid configurations are:
+ * - `'passthrough'`: delegates to the spy's original implementation (default)
+ * - `'throw'`: throws an error
+ * - a function: called with the unmatched arguments; its return value is used
+ *
+ * @default
+ * 'passthrough'
+ *
+ * @example
+ * vi.when(spy, { onUnmatched: 'throw' })
+ * .calledWith(1)
+ * .thenReturn({ id: 1, name: 'Alice' })
+ *
+ * expect(spy(1)).toEqual({ id: 1, name: 'Alice' })
+ * expect(() => spy(2)).toThrow()
+ */
+ onUnmatched?: 'throw' | 'passthrough' | Fn | undefined
+}
+
+/**
+ * Defines conditional behaviors on a Vitest spy based on the arguments it is called with.
+ *
+ * Behaviors are matched using deep equality, last-registered first within each argument set.
+ *
+ * It automatically restores the spy's original implementation when the enclosing block exits if used with the `using` keyword.
+ *
+ * @param spy - A Vitest mock function to attach behaviors to.
+ * @param options - Optional configuration.
+ * @returns A {@linkcode When} instance for registering behaviors.
+ *
+ * @throws {TypeError} If `spy` is not a Vitest mock function.
+ * @throws {Error} If chaining a non-existent method after `calledWith`.
+ * @throws {RangeError} If setting the `times` option on a `then*` method as a negative value or `0`.
+ *
+ * @since 5.0.0
+ * @see {@link https://vitest.dev/api/vi#vi-when}
+ * @see {@link https://vitest.dev/guide/recipes/conditional-mocking}
+ *
+ * @example
+ * // Basic usage
+ * const spy = vi.fn(() => Number.NEGATIVE_INFINITY)
+ * vi.when(spy).calledWith(1).thenReturn(0)
+ *
+ * expect(spy(1)).toBe(0)
+ * expect(spy(2)).toBe(Number.NEGATIVE_INFINITY) // falls through to original implementation
+ *
+ * @example
+ * // Async
+ * const spy = vi.fn()
+ * vi.when(spy).calledWith('user').thenResolve({ id: 1 })
+ *
+ * await expect(spy('user')).resolves.toEqual({ id: 1 })
+ *
+ * @example
+ * // Scoped with `using`
+ * const spy = vi.fn()
+ *
+ * {
+ * using w = vi.when(spy)
+ * .calledWith('darkMode')
+ * .thenReturn(true)
+ *
+ * expect(spy('darkMode')).toBe(true)
+ * }
+ *
+ * // spy's original implementation is restored here
+ * expect(spy('darkMode')).toBe(undefined)
+ *
+ * @example
+ * // Throw on unmatched calls
+ * vi.when(spy, { onUnmatched: 'throw' })
+ * .calledWith(1)
+ * .thenReturn({ id: 1, name: 'Alice' })
+ *
+ * expect(spy(1)).toEqual({ id: 1, name: 'Alice' })
+ * expect(() => spy(2)).toThrow()
+ */
+export function when(spy: Fn | Mock, options?: WhenOptions): When {
+ if (!isMockFunction(spy)) {
+ throw new TypeError('vi.when: the argument must be a mock function created with `vi.fn()` or `vi.spyOn()`')
+ }
+
+ type ScopedParameters = Parameters
+ type ScopedReturn = ReturnType
+
+ const behaviors: Behavior[] = []
+ const originalImplementation = spy.getMockImplementation()
+
+ function findAction(args: ScopedParameters) {
+ const testers = [
+ ...getCustomEqualityTesters(),
+ iterableEquality,
+ ]
+
+ for (const behavior of behaviors) {
+ if (equals(args, behavior.arguments, testers)) {
+ return behavior.actions.findLast(action => !(action.remaining === 0 && action.called)) ?? null
+ }
+ }
+
+ return null
+ }
+
+ spy.mockImplementation(
+ // @ts-expect-error cannot resolve generic args
+ (...args: ScopedParameters) => {
+ const action = findAction(args)
+
+ if (action === null) {
+ const onUnmatched = typeof options?.onUnmatched === 'function'
+ ? options.onUnmatched
+ : options?.onUnmatched === 'throw'
+ ? () => {
+ throw new Error(`vi.when: no behavior defined when called with [${args.map(arg => stringify(arg)).join(', ')}]`)
+ }
+ : originalImplementation
+ return onUnmatched?.(...args)
+ }
+
+ action.remaining -= 1
+ action.called = true
+
+ switch (action.type) {
+ case 'return': {
+ return action.value
+ }
+
+ case 'throw': {
+ throw action.value
+ }
+
+ case 'resolve': {
+ return Promise.resolve(action.value)
+ }
+
+ case 'reject': {
+ return Promise.reject(action.value)
+ }
+ }
+ },
+ )
+
+ function getOrCreateBehavior(args: ScopedParameters) {
+ const testers = [
+ ...getCustomEqualityTesters(),
+ iterableEquality,
+ ]
+
+ let behavior = behaviors.find(behavior => equals(args, behavior.arguments, testers))
+
+ if (behavior === undefined) {
+ behavior = {
+ arguments: args,
+ actions: [],
+ }
+
+ behaviors.push(behavior)
+ }
+
+ return behavior
+ }
+
+ // @ts-expect-error `Symbol.dispose` has to be assigned conditionally since it's only supported in Node >= 24
+ const output: When = markWhenChain({
+ calledWith: (...args: ScopedParameters) => {
+ const behavior = getOrCreateBehavior(args)
+
+ function appendAction(behavior: Behavior, type: BehaviorType, value: unknown, times: number) {
+ behavior.actions.push({
+ type,
+ value,
+ times,
+ remaining: times,
+ called: false,
+ })
+ }
+
+ const calledWithInstance: CalledWithInstance = markWhenChain({
+ ...output,
+ thenThrow: (value, options) => {
+ validateOptions(options)
+ appendAction(behavior, 'throw', value, options?.times ?? Number.POSITIVE_INFINITY)
+
+ return calledWithInstance
+ },
+ thenThrowOnce: (value) => {
+ appendAction(behavior, 'throw', value, 1)
+
+ return calledWithInstance
+ },
+ thenReturn: (value, options) => {
+ validateOptions(options)
+ appendAction(behavior, 'return', value, options?.times ?? Number.POSITIVE_INFINITY)
+
+ return calledWithInstance
+ },
+ thenReturnOnce: (value) => {
+ appendAction(behavior, 'return', value, 1)
+
+ return calledWithInstance
+ },
+ thenResolve: (value, options) => {
+ validateOptions(options)
+ appendAction(behavior, 'resolve', value, options?.times ?? Number.POSITIVE_INFINITY)
+
+ return calledWithInstance
+ },
+ thenResolveOnce: (value) => {
+ appendAction(behavior, 'resolve', value, 1)
+
+ return calledWithInstance
+ },
+ thenReject: (value, options) => {
+ validateOptions(options)
+ appendAction(behavior, 'reject', value, options?.times ?? Number.POSITIVE_INFINITY)
+
+ return calledWithInstance
+ },
+ thenRejectOnce: (value) => {
+ appendAction(behavior, 'reject', value, 1)
+
+ return calledWithInstance
+ },
+ })
+
+ return calledWithInstance
+ },
+ _getDiagnostics: () => {
+ const pendingBehaviors = behaviors
+ .filter(behavior =>
+ behavior.actions.length === 0 || behavior.actions.some(action => !hasBeenConsumed(action)),
+ )
+
+ return {
+ isExhausted: behaviors.length !== 0 && pendingBehaviors.length === 0,
+ pendingBehaviors: pendingBehaviors
+ .map(behavior => `calledWith(${behavior.arguments.map(argument => stringify(argument)).join(', ')})${behavior.actions.length === 0 ? ' → no actions' : `\n${formatActions(behavior.actions)}`}`)
+ .join('\n\n'),
+ }
+ },
+ } satisfies Omit, symbol>)
+
+ if (Symbol.dispose) {
+ output[Symbol.dispose] = () => {
+ spy.mockImplementation(
+ // @ts-expect-error without an original implementation we should fall back to an undefined-returning function as that's what the mocking functions do
+ originalImplementation ?? noop,
+ )
+ }
+ }
+
+ return output
+}
+
+const whenChainMarkerOptions: PropertyDescriptor = {}
+
+function markWhenChain(input: T): T {
+ Reflect.defineProperty(input, whenSymbol, whenChainMarkerOptions)
+
+ return input
+}
+
+function formatActions(actions: Behavior['actions']): string {
+ const lines = actions.map((action, index) => {
+ const method = getMethodName(action.type)
+ const symbol = getSymbol(action)
+ const left = ` ${symbol} ${method}(${stringify(action.value)}${action.times === Number.POSITIVE_INFINITY ? '' : `, { times: ${action.times} }`})`
+ const unreachable = !hasBeenConsumed(action)
+ && actions.slice(index + 1).some(later => later.times === Number.POSITIVE_INFINITY)
+ const remaining = getRemainingLabel(action) + (unreachable ? ' → unreachable action' : '')
+
+ return { left, remaining }
+ })
+
+ const maxLeft = Math.max(...lines.map(line => line.left.length))
+
+ return lines
+ .map(({ left, remaining }) => `${left.padEnd(maxLeft + 2)}${remaining}`)
+ .join('\n')
+}
+
+function getMethodName(type: BehaviorType): string {
+ switch (type) {
+ case 'return': {
+ return 'thenReturn'
+ }
+ case 'resolve': {
+ return 'thenResolve'
+ }
+ case 'throw': {
+ return 'thenThrow'
+ }
+ case 'reject': {
+ return 'thenReject'
+ }
+ default: {
+ (type satisfies never)
+
+ throw new Error(`vi.when: "${type}" is not a known method`)
+ }
+ }
+}
+
+function hasBeenConsumed(action: BehaviorAction): boolean {
+ return action.remaining === 0 /* times-actions reached 0 */
+ || (action.remaining === Number.POSITIVE_INFINITY && action.called) /* infinite actions called at least once */
+}
+
+function getRemainingLabel(action: BehaviorAction): string {
+ if (hasBeenConsumed(action)) {
+ return action.remaining === Number.POSITIVE_INFINITY
+ ? 'exhausted'
+ : `exhausted (${action.times} of ${action.times})`
+ }
+
+ return action.remaining === Number.POSITIVE_INFINITY
+ ? 'never called'
+ : `${action.remaining} remaining (out of ${action.times})`
+}
+
+function getSymbol(action: BehaviorAction): string {
+ if (hasBeenConsumed(action)) {
+ return '✓'
+ }
+
+ return '✗'
+}
+
+function validateOptions(options: BehaviorOptions | undefined) {
+ if (typeof options?.times === 'number' && options.times <= 0) {
+ throw new RangeError('vi.when: `times` option must be greater than 0')
+ }
+}
diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts
index 87988ebe0..72895d109 100644
--- a/packages/vitest/src/integrations/vi.ts
+++ b/packages/vitest/src/integrations/vi.ts
@@ -16,6 +16,7 @@ import { getSafeTimers } from '@vitest/utils/timers'
import { getWorkerState, isChildProcess, resetModules } from '../runtime/utils'
import { parseSingleStack } from '../utils/source-map'
import { FakeTimers } from './mock/timers'
+import { isWhenChain, when } from './mock/when'
import { waitFor, waitUntil } from './wait'
type ESModuleExports = Record
@@ -147,6 +148,9 @@ export interface VitestUtils {
*/
fn: typeof fn
+ when: typeof when
+ isWhenChain: typeof isWhenChain
+
/**
* Wait for the callback to execute successfully. If the callback throws an error or returns a rejected promise it will continue to wait until it succeeds or times out.
*
@@ -610,6 +614,8 @@ function createVitest(): VitestUtils {
spyOn,
fn,
+ when,
+ isWhenChain,
waitFor,
waitUntil,
defineHelper: (fn) => {
diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts
index 084785ddc..84234c0fe 100644
--- a/packages/vitest/src/types/global.ts
+++ b/packages/vitest/src/types/global.ts
@@ -125,5 +125,25 @@ declare module 'vitest' {
expected: BenchResult,
options?: { delta?: number },
) => void
+
+ /**
+ * Ensures a `vi.when` chain has been exhausted.
+ *
+ * A chain is exhausted when at least one `calledWith` with an associated action (`then*`) has been registered
+ * and every registered behavior has been fully consumed. A chain with no registered
+ * behaviors, or with `calledWith` entries that have no associated `then*` actions, is never considered exhausted.
+ *
+ * @see {@link https://vitest.dev/api/expect#tohavebeenexhausted}
+ *
+ * @example
+ * const w = vi.when(spy).calledWith('hello').thenReturnOnce('HELLO')
+ *
+ * expect(w).not.toHaveBeenExhausted()
+ *
+ * expect(spy('hello')).toBe('HELLO')
+ *
+ * expect(w).toHaveBeenExhausted()
+ */
+ toHaveBeenExhausted: () => void
}
}
diff --git a/test/unit/test/mocking/vi-when.test.ts b/test/unit/test/mocking/vi-when.test.ts
new file mode 100644
index 000000000..a7cf24a3d
--- /dev/null
+++ b/test/unit/test/mocking/vi-when.test.ts
@@ -0,0 +1,700 @@
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import * as a from '../../src/mockedA'
+
+vi.mock('../../src/mockedA')
+
+interface FnData {
+ args: [a: string, b: number]
+ value: number
+ entries: { args: FnData['args']; value: FnData['value'] }[]
+}
+
+type Fn = (...args: FnData['args']) => FnData['value']
+
+describe('vi.when()', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('basic usage', () => {
+ test('returns provided value when arguments match', () => {
+ const spy = vi.fn()
+
+ const entries: FnData['entries'] = [
+ { args: ['a', 0], value: 97 },
+ { args: ['b', 1], value: 99 },
+ ]
+
+ const w = vi.when(spy)
+ .calledWith(...entries[0].args)
+ .thenReturn(entries[0].value)
+ .calledWith(...entries[1].args)
+ .thenReturn(entries[1].value)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy(...entries[0].args)).toBe(entries[0].value)
+
+ expect(spy).toHaveBeenLastCalledWith(...entries[0].args)
+ expect(spy).toHaveLastReturnedWith(entries[0].value)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy(...entries[1].args)).toBe(entries[1].value)
+
+ expect(spy).toHaveBeenLastCalledWith(...entries[1].args)
+ expect(spy).toHaveLastReturnedWith(entries[1].value)
+
+ expect(spy).toHaveBeenCalledTimes(2)
+
+ expect(w).toHaveBeenExhausted()
+ })
+
+ test('falls through to original implementation when arguments don\'t match', () => {
+ const spy = vi.fn((a, b) => b * a.charCodeAt(0))
+
+ const args: FnData['args'] = ['a', 0]
+ const value: FnData['value'] = 97
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(value)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy('b', 1)).toBe(98)
+
+ expect(spy).not.toHaveBeenLastCalledWith(...args)
+ expect(spy).not.toHaveLastReturnedWith(value)
+
+ expect(spy).toHaveBeenCalledOnce()
+
+ expect(w).not.toHaveBeenExhausted()
+ })
+
+ test('returns provided value when arguments match using asymmetric matchers', () => {
+ const spy = vi.fn(() => Number.NaN)
+
+ const args: FnData['args'] = [expect.stringContaining('--'), expect.any(Number)]
+ const value: FnData['value'] = 0
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(value)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy('a--z', Number.NEGATIVE_INFINITY)).toBe(value)
+ expect(spy('z--a', Number.POSITIVE_INFINITY)).toBe(value)
+
+ expect(w).toHaveBeenExhausted()
+
+ expect(spy('a__z', Number.NEGATIVE_INFINITY)).toBe(Number.NaN)
+ expect(spy('z__a', Number.NEGATIVE_INFINITY)).toBe(Number.NaN)
+ })
+
+ test('throws when using `toThrow`', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const error = new TypeError('Expected second argument > 0')
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenThrow(error)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(() => spy(...args)).toThrow(error)
+
+ expect(spy).toHaveBeenLastCalledWith(...args)
+
+ expect(spy).toHaveBeenCalledOnce()
+
+ expect(w).toHaveBeenExhausted()
+ })
+
+ test('resolves a promise when using `toResolve`', async () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const value: FnData['value'] = 97
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenResolve(value)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ await expect(spy(...args)).resolves.toBe(value)
+
+ expect(spy).toHaveBeenLastCalledWith(...args)
+ expect(spy).toHaveLastResolvedWith(value)
+
+ expect(spy).toHaveBeenCalledOnce()
+
+ expect(w).toHaveBeenExhausted()
+ })
+
+ test('rejects a promise when using `toReject`', async () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const error = new TypeError('Expected second argument > 0')
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReject(error)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ await expect(spy(...args)).rejects.toThrow(error)
+
+ expect(spy).toHaveBeenLastCalledWith(...args)
+
+ expect(spy).toHaveBeenCalledOnce()
+
+ expect(w).toHaveBeenExhausted()
+ })
+
+ test.runIf(Symbol.dispose)('disposes of its mock', () => {
+ const firstSpy = vi.fn((a, b) => b * a.charCodeAt(0))
+ const secondSpy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const value: FnData['value'] = 97
+
+ {
+ using w = vi.when(firstSpy)
+ .calledWith(...args)
+ .thenReturn(value)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(firstSpy(...args)).toBe(value)
+ expect(w).toHaveBeenExhausted()
+ }
+
+ expect(firstSpy(...args)).toBe(0)
+
+ {
+ using w = vi.when(secondSpy)
+ .calledWith(...args)
+ .thenReturn(value)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(secondSpy(...args)).toBe(value)
+ expect(w).toHaveBeenExhausted()
+ }
+
+ expect(secondSpy(...args)).toBe(undefined)
+ })
+ })
+
+ describe('non-matching behavior', () => {
+ test('falls through to original implementation when `onUnmatched` is set to "passthrough"', () => {
+ const spy = vi.fn((a, b) => b * a.charCodeAt(0))
+
+ vi.when(spy, { onUnmatched: 'passthrough' })
+ .calledWith('a', 0)
+ .thenReturn(97)
+
+ expect(spy('b', 1)).toBe(98)
+ })
+
+ test('throws when `onUnmatched` is set to "throw"', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const value: FnData['value'] = 97
+
+ vi.when(spy, { onUnmatched: 'throw' })
+ .calledWith(...args)
+ .thenReturn(value)
+
+ expect(spy(...args)).toBe(value)
+ expect(() => spy('b', 1)).toThrowErrorMatchingInlineSnapshot(`[Error: vi.when: no behavior defined when called with ["b", 1]]`)
+ })
+
+ test('calls the provided function when `onUnmatched` is a function', () => {
+ const spy = vi.fn()
+ const fallback = vi.fn(() => 0)
+
+ const args: FnData['args'] = ['a', 0]
+ const value: FnData['value'] = 97
+
+ vi.when(spy, { onUnmatched: fallback })
+ .calledWith(...args)
+ .thenReturn(value)
+
+ expect(spy(...args)).toBe(value)
+ expect(spy('b', 1)).toBe(0)
+ expect(fallback).toHaveBeenCalledWith('b', 1)
+ })
+ })
+
+ describe('exhausting behaviors', () => {
+ test('returns the provided value as described by the `times` option', async () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const values: FnData['value'][] = [
+ 97,
+ 98,
+ 99,
+ ]
+ const throwError = new TypeError('[throw] Expected second argument > 0')
+ const rejectError = new TypeError('[reject] Expected second argument > 0')
+
+ const times = 2
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(values[0])
+ .calledWith(...args)
+ .thenReturn(values[1], { times })
+ .calledWith(...args)
+ .thenThrow(throwError, { times })
+ .calledWith(...args)
+ .thenResolve(values[2], { times })
+ .calledWith(...args)
+ .thenReject(rejectError, { times })
+
+ expect(w).not.toHaveBeenExhausted()
+
+ for (let i = 0; i < times; i += 1) {
+ await expect(spy(...args)).rejects.toThrow(rejectError)
+ expect(w).not.toHaveBeenExhausted()
+ }
+
+ expect(spy).toHaveBeenCalledTimes(times)
+
+ for (let i = 0; i < times; i += 1) {
+ await expect(spy(...args)).resolves.toBe(values[2])
+ expect(w).not.toHaveBeenExhausted()
+ }
+
+ expect(spy).toHaveBeenCalledTimes(times * 2)
+
+ for (let i = 0; i < times; i += 1) {
+ expect(() => spy(...args)).toThrow(throwError)
+ expect(w).not.toHaveBeenExhausted()
+ }
+
+ expect(spy).toHaveBeenCalledTimes(times * 3)
+
+ for (let i = 0; i < times; i += 1) {
+ expect(spy(...args)).toBe(values[1])
+ expect(w).not.toHaveBeenExhausted()
+ }
+
+ expect(spy).toHaveBeenCalledTimes(times * 4)
+
+ expect(spy(...args)).toBe(values[0])
+ expect(w).toHaveBeenExhausted()
+
+ expect(spy).toHaveBeenCalledTimes(times * 4 + 1)
+ })
+
+ test('multiple behaviors can be chained on a single `calledWith` call', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const values: FnData['value'][] = [
+ 97,
+ 98,
+ ]
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(values[0])
+ .thenReturn(values[1], { times: 2 })
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy(...args)).toBe(values[1])
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy(...args)).toBe(values[1])
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy(...args)).toBe(values[0])
+ expect(w).toHaveBeenExhausted()
+
+ expect(spy).toHaveBeenCalledTimes(3)
+ })
+
+ test('groups behaviors based on asymmetric matchers correctly', () => {
+ const spy = vi.fn()
+
+ const value: FnData['value'] = 0
+ const once: FnData['value'] = 1
+
+ const w = vi.when(spy)
+ .calledWith(expect.stringContaining('--'), expect.any(Number))
+ .thenReturn(value)
+ .calledWith(expect.stringContaining('--'), expect.any(Number))
+ .thenReturnOnce(once)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy('a--z', 0)).toBe(once)
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy('a--z', 1)).toBe(value)
+ expect(w).toHaveBeenExhausted()
+ })
+
+ test('`*Once` behaviors are sugar syntax for `times: 1`', async () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const values: FnData['value'][] = [
+ 97,
+ 98,
+ 99,
+ ]
+ const throwError = new TypeError('[throw] Expected second argument > 0')
+ const rejectError = new TypeError('[reject] Expected second argument > 0')
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(values[0])
+ .thenReturnOnce(values[1])
+ .thenThrowOnce(throwError)
+ .thenResolveOnce(values[2])
+ .thenRejectOnce(rejectError)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ await expect(spy(...args)).rejects.toThrow(rejectError)
+ expect(w).not.toHaveBeenExhausted()
+
+ await expect(spy(...args)).resolves.toBe(values[2])
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(() => spy(...args)).toThrow(throwError)
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy(...args)).toBe(values[1])
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(spy(...args)).toBe(values[0])
+ expect(w).toHaveBeenExhausted()
+
+ expect(spy).toHaveBeenCalledTimes(5)
+ })
+
+ test('resolves behaviors in a LIFO-style manner', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const values: FnData['value'][] = [
+ 97,
+ 98,
+ 99,
+ ]
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(values[0])
+ .thenReturnOnce(values[1])
+ .thenReturn(values[2])
+
+ expect(spy(...args)).toBe(values[2])
+
+ expect(spy).toHaveBeenLastCalledWith(...args)
+ expect(spy).toHaveLastReturnedWith(values[2])
+
+ expect(spy(...args)).toBe(values[2])
+
+ expect(spy).toHaveBeenLastCalledWith(...args)
+ expect(spy).toHaveLastReturnedWith(values[2])
+
+ expect(spy(...args)).toBe(values[2])
+
+ expect(spy).toHaveBeenLastCalledWith(...args)
+ expect(spy).toHaveLastReturnedWith(values[2])
+
+ expect(spy).toHaveBeenCalledTimes(3)
+
+ expect(w).not.toHaveBeenExhausted() // still `false` as the first two behaviors cannot be reached
+ })
+ })
+
+ describe('module mocks and spies', () => {
+ test('works with vi.mock()', () => {
+ expect(a.mockedA()).toBe(undefined)
+
+ const w = vi.when(a.mockedA).calledWith().thenReturnOnce('B')
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(a.mockedA()).toBe('B')
+ expect(w).toHaveBeenExhausted()
+
+ expect(a.mockedA()).toBe(undefined)
+ })
+
+ test('works with vi.spyOn()', () => {
+ const message = 'ResizeObserver loop completed with undelivered notifications.'
+ const value = 'not undefined'
+
+ const w = vi.when(vi.spyOn(console, 'error'))
+ .calledWith(expect.objectContaining({ message }))
+ .thenReturn(value as never)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ expect(console.error({ message })).toBe(value)
+ expect(w).toHaveBeenExhausted()
+
+ expect(console.error({ message: message.slice(0, 14) })).toBe(undefined)
+ })
+ })
+
+ describe('state snapshots', () => {
+ test('multiple `calledWith` stacks', () => {
+ const spy = vi.fn()
+
+ const entries: FnData['entries'] = [
+ { args: ['a', 0], value: 97 },
+ { args: ['b', 1], value: 99 },
+ ]
+
+ const w = vi.when(spy)
+ .calledWith(...entries[0].args)
+ .thenReturn(entries[0].value)
+ .thenReturnOnce(entries[0].value)
+ .calledWith(...entries[1].args)
+ .thenReturn(entries[1].value)
+
+ let d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`
+ "calledWith("a", 0)
+ ✗ thenReturn(97) never called
+ ✗ thenReturn(97, { times: 1 }) 1 remaining (out of 1)
+
+ calledWith("b", 1)
+ ✗ thenReturn(99) never called"
+ `)
+
+ expect(w).not.toHaveBeenExhausted()
+ expect(() => expect(w).toHaveBeenExhausted()).toThrowErrorMatchingInlineSnapshot(`
+ [AssertionError: expected all behaviors to have been exhausted, but some remain:
+
+ calledWith("a", 0)
+ ✗ thenReturn(97) never called
+ ✗ thenReturn(97, { times: 1 }) 1 remaining (out of 1)
+
+ calledWith("b", 1)
+ ✗ thenReturn(99) never called]
+ `)
+
+ spy(...entries[0].args)
+
+ d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`
+ "calledWith("a", 0)
+ ✗ thenReturn(97) never called
+ ✓ thenReturn(97, { times: 1 }) exhausted (1 of 1)
+
+ calledWith("b", 1)
+ ✗ thenReturn(99) never called"
+ `)
+
+ expect(w).not.toHaveBeenExhausted()
+ expect(() => expect(w).toHaveBeenExhausted()).toThrowErrorMatchingInlineSnapshot(`
+ [AssertionError: expected all behaviors to have been exhausted, but some remain:
+
+ calledWith("a", 0)
+ ✗ thenReturn(97) never called
+ ✓ thenReturn(97, { times: 1 }) exhausted (1 of 1)
+
+ calledWith("b", 1)
+ ✗ thenReturn(99) never called]
+ `)
+
+ spy(...entries[0].args)
+
+ d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`
+ "calledWith("b", 1)
+ ✗ thenReturn(99) never called"
+ `)
+
+ expect(w).not.toHaveBeenExhausted()
+ expect(() => expect(w).toHaveBeenExhausted()).toThrowErrorMatchingInlineSnapshot(`
+ [AssertionError: expected all behaviors to have been exhausted, but some remain:
+
+ calledWith("b", 1)
+ ✗ thenReturn(99) never called]
+ `)
+
+ spy(...entries[1].args)
+
+ d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(true)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`""`)
+
+ expect(() => expect(w).not.toHaveBeenExhausted()).toThrowErrorMatchingInlineSnapshot(`
+ [AssertionError: expected at least one behavior to remain un-exhausted, but all were]
+ `)
+ expect(w).toHaveBeenExhausted()
+ })
+
+ test('points out unreachable actions', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const value: FnData['value'] = 97
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(value)
+ .thenReturn(value + 1)
+
+ let d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`
+ "calledWith("a", 0)
+ ✗ thenReturn(97) never called → unreachable action
+ ✗ thenReturn(98) never called"
+ `)
+
+ spy(...args)
+
+ d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`
+ "calledWith("a", 0)
+ ✗ thenReturn(97) never called → unreachable action
+ ✓ thenReturn(98) exhausted"
+ `)
+ })
+
+ test('prints different return methods correctly', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+ const value: FnData['value'] = 97
+ const error = new TypeError('Expected second argument > 0')
+
+ const times = 2
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(value, { times })
+ .thenThrow(error, { times })
+ .thenResolve(value, { times })
+ .thenReject(error, { times })
+
+ const d = w._getDiagnostics()
+
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`
+ "calledWith("a", 0)
+ ✗ thenReturn(97, { times: 2 }) 2 remaining (out of 2)
+ ✗ thenThrow([TypeError: Expected second argument > 0], { times: 2 }) 2 remaining (out of 2)
+ ✗ thenResolve(97, { times: 2 }) 2 remaining (out of 2)
+ ✗ thenReject([TypeError: Expected second argument > 0], { times: 2 }) 2 remaining (out of 2)"
+ `)
+ })
+
+ test('formats asymmetric matchers correctly', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = [expect.stringContaining('--'), expect.any(Number)]
+ const value: FnData['value'] = 0
+
+ const w = vi.when(spy)
+ .calledWith(...args)
+ .thenReturn(value)
+
+ const d = w._getDiagnostics()
+
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`
+ "calledWith(StringContaining "--", Any)
+ ✗ thenReturn(0) never called"
+ `)
+ })
+ })
+
+ describe('edge cases', () => {
+ test('is not exhausted when no behaviors are registered', () => {
+ const spy = vi.fn()
+
+ const w = vi.when(spy)
+
+ const d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toBe('')
+
+ expect(w).not.toHaveBeenExhausted()
+ })
+
+ test('is not exhausted when a behavior with no actions is registered', () => {
+ const spy = vi.fn()
+
+ const args: FnData['args'] = ['a', 0]
+
+ const w = vi.when(spy).calledWith(...args)
+
+ let d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`"calledWith("a", 0) → no actions"`)
+
+ expect(w).not.toHaveBeenExhausted()
+
+ spy(...args)
+
+ d = w._getDiagnostics()
+
+ expect(d.isExhausted).toBe(false)
+ expect(d.pendingBehaviors).toMatchInlineSnapshot(`"calledWith("a", 0) → no actions"`)
+
+ expect(w).not.toHaveBeenExhausted()
+ })
+
+ test('throws when not used with a mock', () => {
+ expect(() => {
+ vi.when(() => {})
+ }).toThrowErrorMatchingInlineSnapshot(`[TypeError: vi.when: the argument must be a mock function created with \`vi.fn()\` or \`vi.spyOn()\`]`)
+ })
+
+ test('throws error when `times` option is not greater than 0', () => {
+ expect(() => {
+ vi.when(vi.fn()).calledWith(0).thenReturn(0, { times: 0 })
+ }).toThrowErrorMatchingInlineSnapshot(`[RangeError: vi.when: \`times\` option must be greater than 0]`)
+
+ expect(() => {
+ vi.when(vi.fn()).calledWith(0).thenReturn(0, { times: -1 })
+ }).toThrowErrorMatchingInlineSnapshot(`[RangeError: vi.when: \`times\` option must be greater than 0]`)
+ })
+ })
+})
+
+describe('vi.isWhenChain()', () => {
+ test('returns true on objects returned by `vi.when()`', () => {
+ expect(vi.isWhenChain(vi.when(vi.fn()))).toBe(true)
+ expect(vi.isWhenChain(vi.when(vi.fn()).calledWith())).toBe(true)
+ expect(vi.isWhenChain(vi.when(vi.fn()).calledWith().thenReturn(undefined))).toBe(true)
+ })
+
+ test('returns false on other objects', () => {
+ expect(vi.isWhenChain(vi.fn())).toBe(false)
+ expect(vi.isWhenChain(() => {})).toBe(false)
+ expect(vi.isWhenChain(Object.create(null))).toBe(false)
+ })
+})