From 3900e634991fddc7eac6db3d63417411c11d0dae Mon Sep 17 00:00:00 2001 From: Raul Macarie Date: Thu, 18 Jun 2026 10:25:52 +0200 Subject: [PATCH] feat(vitest): create `vi.when()` (#10174) --- docs/.vitepress/config.ts | 4 + docs/api/expect.md | 32 + docs/api/vi.md | 128 ++++ docs/guide/learn/mock-functions.md | 4 + docs/guide/mocking/functions.md | 4 + docs/guide/recipes/conditional-mocking.md | 270 +++++++ packages/expect/src/index.ts | 2 +- .../vitest/src/integrations/chai/setup.ts | 2 + packages/vitest/src/integrations/mock/chai.ts | 27 + packages/vitest/src/integrations/mock/when.ts | 549 ++++++++++++++ packages/vitest/src/integrations/vi.ts | 6 + packages/vitest/src/types/global.ts | 20 + test/unit/test/mocking/vi-when.test.ts | 700 ++++++++++++++++++ 13 files changed, 1747 insertions(+), 1 deletion(-) create mode 100644 docs/guide/recipes/conditional-mocking.md create mode 100644 packages/vitest/src/integrations/mock/chai.ts create mode 100644 packages/vitest/src/integrations/mock/when.ts create mode 100644 test/unit/test/mocking/vi-when.test.ts 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) + }) +}) -- 2.51.2