Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 92 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Wrap your `vi.fn()` mock - or a function imported from a `vi.mock`'d module - in
- [`.thenThrow()`][then-throw] - Throw an error
- [`.thenReject()`][then-reject] - Reject a `Promise`
- [`.thenDo()`][then-do] - Trigger a function
- [`.thenCallback()`][then-callback] - Invoke a callback argument

If the stub is called with arguments that match `calledWith`, the configured behavior will occur. If the arguments do not match, the stub will no-op and return `undefined`.

Expand Down Expand Up @@ -76,6 +77,7 @@ You should call `vi.resetAllMocks()` in your suite's `afterEach` hook to remove
[then-throw]: #thenthrowerror-unknown---mocktfunc
[then-reject]: #thenrejecterror-unknown---mocktfunc
[then-do]: #thendocallback-args-targs--treturn---mocktfunc
[then-callback]: #thencallbackargs-unknown---mocktfunc

### Why not vanilla Vitest mocks?

Expand Down Expand Up @@ -205,9 +207,10 @@ expect(mock()).toBe(undefined)
import type { WhenOptions } from 'vitest-when'
```

| option | default | type | description |
| ------- | ------- | ------- | -------------------------------------------------- |
| `times` | N/A | integer | Only trigger configured behavior a number of times |
| option | default | type | description |
| ----------------- | ------- | ------- | -------------------------------------------------- |
| `times` | N/A | integer | Only trigger configured behavior a number of times |
| `ignoreExtraArgs` | `false` | boolean | Match calls even when extra arguments are provided |

### `.calledWith(...args: Parameters<TFunc>): Stub<TFunc>`

Expand Down Expand Up @@ -454,6 +457,92 @@ expect(mock('hello')).toEqual('world')
expect(mock('hello')).toEqual('solar system')
```

### `.thenCallback(...args: unknown[]) -> Mock<TFunc>`

When the stubbing is satisfied, invoke a callback function passed as an argument with the specified `args`. Use with [`expect.callback()`][expect-callback] to match callback arguments.

```ts
const mock = when(vi.fn())
.calledWith('data', expect.callback())
.thenCallback('result')

mock('data', (value) => {
console.log(value) // 'result'
})
```

If you don't specify `expect.callback()` in `calledWith`, an implied callback matcher is added as the last argument:

```ts
const mock = when(vi.fn()).calledWith('data').thenCallback('result')

mock('data', (value) => {
console.log(value) // 'result'
})
```

Callbacks are invoked asynchronously on the next tick. Use [`nextTick()`][next-tick] to wait for callback execution:

```ts
import { when, nextTick } from 'vitest-when'

const callback = vi.fn()
const mock = when(vi.fn()).calledWith('hello').thenCallback('world')

mock('hello', callback)

await nextTick()
expect(callback).toHaveBeenCalledWith('world')
```

You can specify callback arguments directly in `expect.callback()` instead of `thenCallback()`:

```ts
const mock = when(vi.fn())
.calledWith(expect.callback('first'), expect.callback('second'))
.thenReturn('done')

const cb1 = vi.fn()
const cb2 = vi.fn()

mock(cb1, cb2)

await nextTick()
expect(cb1).toHaveBeenCalledWith('first')
expect(cb2).toHaveBeenCalledWith('second')
```

[expect-callback]: #expectcallbackargs-unknown
[next-tick]: #nexttick

### `expectCallback(...args: unknown[])`

This is the same as `expect.callback()`, but exported as `expectCallback` for use when the your tests use the context's `expect` function (e.g. `it('test', ({ expect }) => { ... }))`).

```ts
import { when, expectCallback } from 'vitest-when'

when(mock).calledWith(expectCallback('arg')).thenReturn('result')
// or
when(mock).calledWith(expect.callback('arg')).thenReturn('result')
```

### `nextTick()`

Returns a `Promise` that resolves on the next tick. Required when testing callbacks with `thenCallback()` since callbacks are invoked asynchronously.

```ts
import { when, nextTick } from 'vitest-when'

const callback = vi.fn()
when(mock).calledWith('test').thenCallback('value')

mock('test', callback)

await nextTick()
expect(callback).toHaveBeenCalledWith('value')
```

### `debug(mock: TFunc, options?: DebugOptions): DebugResult`

Logs and returns information about a mock's stubbing and usage. Useful if a test with mocks is failing and you can't figure out why.
Expand Down
213 changes: 71 additions & 142 deletions src/behaviors.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,58 @@
import { equals } from '@vitest/expect'

import { concatImpliedCallback, invokeCallbackFor } from './callback.ts'
import type {
AnyFunction,
AnyMockable,
AsFunction,
ParametersOf,
ReturnTypeOf,
WithMatchers,
} from './types.ts'

export interface WhenOptions {
ignoreExtraArgs?: boolean
times?: number
}

export interface PlanOptions extends WhenOptions {
plan: StubbingPlan
}

export type StubbingPlan =
| 'thenCallback'
| 'thenDo'
| 'thenReject'
| 'thenResolve'
| 'thenReturn'
| 'thenThrow'

export interface UseAction {
plan: StubbingPlan
value: unknown
}

export interface BehaviorStack<TFunc extends AnyMockable> {
use: (
args: ParametersOf<TFunc>,
) => BehaviorEntry<ParametersOf<TFunc>> | undefined
use: (args: ParametersOf<TFunc>, fallbackValue?: AnyFunction) => UseAction

getAll: () => readonly BehaviorEntry<ParametersOf<TFunc>>[]

getUnmatchedCalls: () => readonly ParametersOf<TFunc>[]

bindArgs: (
addStubbing: (
args: WithMatchers<ParametersOf<TFunc>>,
options: WhenOptions,
) => BoundBehaviorStack<TFunc>
}

export interface BoundBehaviorStack<TFunc extends AnyMockable> {
addReturn: (values: ReturnTypeOf<TFunc>[]) => void
addResolve: (values: Awaited<ReturnTypeOf<TFunc>>[]) => void
addThrow: (values: unknown[]) => void
addReject: (values: unknown[]) => void
addDo: (values: AsFunction<TFunc>[]) => void
values: unknown[],
options: PlanOptions,
) => void
}

export interface BehaviorEntry<TArgs extends unknown[]> {
export interface BehaviorEntry<
TArgs extends unknown[] = never[],
TValue = unknown,
> {
args: WithMatchers<TArgs>
behavior: Behavior
calls: TArgs[]
maxCallCount?: number | undefined
}

export const BehaviorType = {
RETURN: 'return',
RESOLVE: 'resolve',
THROW: 'throw',
REJECT: 'reject',
DO: 'do',
} as const

export type Behavior =
| { type: typeof BehaviorType.RETURN; value: unknown }
| { type: typeof BehaviorType.RESOLVE; value: unknown }
| { type: typeof BehaviorType.THROW; error: unknown }
| { type: typeof BehaviorType.REJECT; error: unknown }
| { type: typeof BehaviorType.DO; callback: AnyFunction }

export interface BehaviorOptions<TValue> {
value: TValue
maxCallCount: number | undefined
callCount: number
options: PlanOptions
values: TValue[]
}

export const createBehaviorStack = <
Expand All @@ -74,123 +66,60 @@ export const createBehaviorStack = <

getUnmatchedCalls: () => unmatchedCalls,

use: (args) => {
const behavior = behaviors
.filter((b) => behaviorAvailable(b))
.find(behaviorMatches(args))
use: (args, fallbackValue) => {
const behavior = behaviors.find(behaviorMatches(args))

if (!behavior) {
unmatchedCalls.push(args)
return undefined
return { plan: 'thenDo', value: fallbackValue }
}

const value = getCurrentStubbedValue(behavior)

behavior.calls.push(args)
return behavior
},
behavior.callCount++

bindArgs: (args, options) => ({
addReturn: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
args,
maxCallCount,
behavior: { type: BehaviorType.RETURN, value },
calls: [],
}),
),
)
},
addResolve: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
args,
maxCallCount,
behavior: { type: BehaviorType.RESOLVE, value },
calls: [],
}),
),
)
},
addThrow: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
args,
maxCallCount,
behavior: { type: BehaviorType.THROW, error: value },
calls: [],
}),
),
)
},
addReject: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
args,
maxCallCount,
behavior: { type: BehaviorType.REJECT, error: value },
calls: [],
}),
),
)
},
addDo: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
args,
maxCallCount,
behavior: {
type: BehaviorType.DO,
callback: value as AnyFunction,
},
calls: [],
}),
),
)
},
}),
}
}
invokeCallbackFor(behavior, args)

const getBehaviorOptions = <TValue>(
values: TValue[],
{ times }: WhenOptions,
): BehaviorOptions<TValue>[] => {
if (values.length === 0) {
values = [undefined as TValue]
}
return { plan: behavior.options.plan, value }
},

return values.map((value, index) => ({
value,
maxCallCount: times ?? (index < values.length - 1 ? 1 : undefined),
}))
}
addStubbing: (rawArgs, values, options) => {
const args =
options.plan === 'thenCallback'
? concatImpliedCallback(rawArgs)
: rawArgs

const behaviorAvailable = <TArgs extends unknown[]>(
behavior: BehaviorEntry<TArgs>,
): boolean => {
return (
behavior.maxCallCount === undefined ||
behavior.calls.length < behavior.maxCallCount
)
behaviors.unshift({ args, callCount: 0, calls: [], options, values })
},
}
}

const behaviorMatches = <TArgs extends unknown[]>(args: TArgs) => {
return (behavior: BehaviorEntry<TArgs>): boolean => {
let index = 0
const behaviorMatches = (actualArguments: unknown[]) => {
return (behavior: BehaviorEntry<unknown[]>): boolean => {
const { callCount, options } = behavior
const { times } = options

while (index < args.length || index < behavior.args.length) {
if (!equals(args[index], behavior.args[index])) {
return false
}
// Check whether stubbing has been used too many times
if (times !== undefined && callCount >= times) return false

index += 1
}
// Check arity
const expectedArguments = behavior.args
const { ignoreExtraArgs } = options
if (expectedArguments.length !== actualArguments.length && !ignoreExtraArgs)
return false

return true
// Check arguments
return expectedArguments.every((expectedArgument, index) => {
return equals(actualArguments[index], expectedArgument)
})
}
}

const getCurrentStubbedValue = (
behavior: BehaviorEntry<unknown[]>,
): unknown => {
const { callCount, values } = behavior
const hasMoreValues = callCount < values.length
return hasMoreValues ? values[callCount] : values.at(-1)
}
Loading