Skip to content

Commit 3900e63

Browse files
authored
feat(vitest): create vi.when() (#10174)
1 parent dd62b84 commit 3900e63

13 files changed

Lines changed: 1747 additions & 1 deletion

File tree

docs/.vitepress/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,10 @@ export default ({ mode }: { mode: string }) => {
10471047
text: 'Auto-Cleanup with `using`',
10481048
link: '/guide/recipes/explicit-resources',
10491049
},
1050+
{
1051+
text: 'Conditional Mocking with `vi.when`',
1052+
link: '/guide/recipes/conditional-mocking',
1053+
},
10501054
{
10511055
text: 'Per-File Isolation Settings',
10521056
link: '/guide/recipes/disable-isolation',

docs/api/expect.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,38 @@ test('spy function returns bananas on second call', async () => {
14531453
})
14541454
```
14551455

1456+
## toHaveBeenExhausted <Version>5.0.0</Version> {#tohavebeenexhausted}
1457+
1458+
- **Type:** `() => void`
1459+
1460+
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.
1461+
1462+
Requires a `When` chain returned by `vi.when` to be passed to `expect`.
1463+
1464+
```ts
1465+
import { expect, test, vi } from 'vitest'
1466+
1467+
test('all behaviors were consumed', () => {
1468+
const spy = vi.fn()
1469+
const w = vi.when(spy)
1470+
.calledWith(1)
1471+
.thenReturnOnce('once')
1472+
.calledWith(2)
1473+
.thenReturn('always')
1474+
1475+
expect(w).not.toHaveBeenExhausted()
1476+
1477+
spy(1) // consumes the `thenReturnOnce` behavior
1478+
spy(2) // satisfies the `thenReturn` behavior (called at least once)
1479+
1480+
expect(w).toHaveBeenExhausted()
1481+
})
1482+
```
1483+
1484+
::: warning
1485+
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.
1486+
:::
1487+
14561488
## called <Version>4.1.0</Version> {#called}
14571489

14581490
- **Type:** `Assertion` (property, not a method)

docs/api/vi.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,134 @@ globalThis.IntersectionObserver === undefined
797797
IntersectionObserver === undefined
798798
```
799799
800+
### vi.when <Version>5.0.0</Version> {#vi-when}
801+
802+
```ts
803+
interface WhenOptions {
804+
onUnmatched?: 'throw' | 'passthrough' | ((...args: unknown[]) => unknown)
805+
}
806+
807+
interface BehaviorOptions {
808+
times?: number
809+
}
810+
811+
function when(spy: Mock, options?: WhenOptions): When
812+
```
813+
814+
Defines per-argument behaviors on a spy, replacing its implementation for the duration of the `when` chain.
815+
816+
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()`.
817+
818+
```ts
819+
const spy = vi.fn()
820+
821+
vi.when(spy)
822+
.calledWith(1)
823+
.thenReturn('one')
824+
.calledWith(2)
825+
.thenReturn('two')
826+
827+
expect(spy(1)).toBe('one')
828+
expect(spy(2)).toBe('two')
829+
```
830+
831+
Available `then*` methods:
832+
833+
| Method | Description |
834+
|--------|-------------|
835+
| `thenReturn(value, options?)` | Returns `value`. |
836+
| `thenReturnOnce(value)` | Returns `value` once, then falls back. |
837+
| `thenThrow(error, options?)` | Throws `error`. |
838+
| `thenThrowOnce(error)` | Throws `error` once, then falls back. |
839+
| `thenResolve(value, options?)` | Returns a resolved `Promise` with `value`. |
840+
| `thenResolveOnce(value)` | Resolves once, then falls back. |
841+
| `thenReject(error, options?)` | Returns a rejected `Promise` with `error`. |
842+
| `thenRejectOnce(error)` | Rejects once, then falls back. |
843+
844+
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.
845+
846+
```ts
847+
const spy = vi.fn<(key: string) => string>()
848+
849+
vi.when(spy)
850+
.calledWith('theme')
851+
.thenReturn('light') // fallback, applies indefinitely
852+
.thenReturn('dark', { times: 2 }) // applied first for the next 2 calls
853+
854+
expect(spy('theme')).toBe('dark')
855+
expect(spy('theme')).toBe('dark')
856+
expect(spy('theme')).toBe('light') // falls back
857+
```
858+
859+
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:
860+
861+
- `'passthrough'` (**default**): delegates to the spy's original implementation
862+
- `'throw'`: throws an error listing the unmatched arguments
863+
- a function: called with the unmatched arguments; its return value is used
864+
865+
```ts
866+
const spy = vi.fn<(id: number) => string>()
867+
868+
vi.when(spy, { onUnmatched: 'throw' })
869+
.calledWith(1)
870+
.thenReturn('Alice')
871+
872+
expect(spy(1)).toBe('Alice')
873+
expect(() => spy(99)).toThrow() // no behavior defined for 99
874+
```
875+
876+
The `When` object returned by `vi.when` supports the [`toHaveBeenExhausted` assertion](/api/expect#tohavebeenexhausted), which passes once every registered behavior has been consumed.
877+
878+
```ts
879+
const spy = vi.fn()
880+
const w = vi.when(spy)
881+
.calledWith(1)
882+
.thenReturnOnce('once')
883+
.calledWith(2)
884+
.thenReturn('always')
885+
886+
expect(w).not.toHaveBeenExhausted()
887+
888+
spy(1) // consumes the `thenReturnOnce` behavior
889+
spy(2) // satisfies `thenReturn` (called at least once)
890+
891+
expect(w).toHaveBeenExhausted()
892+
```
893+
894+
::: tip
895+
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:
896+
897+
```ts
898+
const spy = vi.fn(() => 'original')
899+
900+
{
901+
using w = vi.when(spy)
902+
.calledWith('hello')
903+
.thenReturn('mocked')
904+
905+
expect(spy('hello')).toBe('mocked')
906+
} // ← spy's original implementation is restored here
907+
908+
expect(spy('hello')).toBe('original')
909+
```
910+
:::
911+
912+
### vi.isWhenChain <Version>5.0.0</Version> {#vi-iswhenchain}
913+
914+
```ts
915+
function isWhenChain(input: object): input is When
916+
```
917+
918+
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.
919+
920+
```ts
921+
const spy = vi.fn()
922+
const w = vi.when(spy).calledWith(1).thenReturn(0)
923+
924+
expect(vi.isWhenChain(w)).toBe(true)
925+
expect(vi.isWhenChain(spy)).toBe(false)
926+
```
927+
800928
## Fake Timers
801929

802930
This sections describes how to work with [fake timers](/guide/mocking/timers).

docs/guide/learn/mock-functions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ test('mock async return values', async () => {
7272
})
7373
```
7474

75+
::: tip
76+
`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.
77+
:::
78+
7579
## Mock Implementation
7680

7781
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:

docs/guide/mocking/functions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ If you need to pass down a custom function implementation as an argument or crea
88

99
Both `vi.spyOn` and `vi.fn` share the same methods.
1010

11+
::: tip
12+
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.
13+
:::
14+
1115
## Example
1216

1317
```js

0 commit comments

Comments
 (0)