Skip to content

Commit 3077558

Browse files
committed
Add .thenCallback() stubbing
1 parent f572142 commit 3077558

File tree

7 files changed

+219
-38
lines changed

7 files changed

+219
-38
lines changed

src/behaviors.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { equals } from '@vitest/expect'
22

3-
import type { AnyMockable, ParametersOf, WithMatchers } from './types.ts'
3+
import { concatImpliedCallback, invokeCallbackFor } from './callback.ts'
4+
import type {
5+
AnyFunction,
6+
AnyMockable,
7+
ParametersOf,
8+
WithMatchers,
9+
} from './types.ts'
410

511
export interface WhenOptions {
612
ignoreExtraArgs?: boolean
@@ -12,20 +18,20 @@ export interface PlanOptions extends WhenOptions {
1218
}
1319

1420
export type StubbingPlan =
21+
| 'thenCallback'
1522
| 'thenDo'
1623
| 'thenReject'
1724
| 'thenResolve'
1825
| 'thenReturn'
1926
| 'thenThrow'
2027

2128
export interface UseAction {
22-
callCount: number
2329
plan: StubbingPlan
24-
values: unknown[]
30+
value: unknown
2531
}
2632

2733
export interface BehaviorStack<TFunc extends AnyMockable> {
28-
use: (args: ParametersOf<TFunc>, fallbackValue: unknown) => UseAction
34+
use: (args: ParametersOf<TFunc>, fallbackValue?: AnyFunction) => UseAction
2935

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

@@ -65,31 +71,32 @@ export const createBehaviorStack = <
6571

6672
if (!behavior) {
6773
unmatchedCalls.push(args)
68-
return { callCount: 0, plan: 'thenDo', values: [fallbackValue] }
74+
return { plan: 'thenDo', value: fallbackValue }
6975
}
7076

77+
const value = getCurrentStubbedValue(behavior)
78+
7179
behavior.calls.push(args)
72-
return {
73-
callCount: behavior.callCount++,
74-
plan: behavior.options.plan,
75-
values: behavior.values,
76-
}
80+
behavior.callCount++
81+
82+
invokeCallbackFor(behavior, args)
83+
84+
return { plan: behavior.options.plan, value }
7785
},
7886

79-
addStubbing: (args, values, options) => {
80-
behaviors.unshift({
81-
args,
82-
callCount: 0,
83-
calls: [],
84-
options,
85-
values,
86-
})
87+
addStubbing: (rawArgs, values, options) => {
88+
const args =
89+
options.plan === 'thenCallback'
90+
? concatImpliedCallback(rawArgs)
91+
: rawArgs
92+
93+
behaviors.unshift({ args, callCount: 0, calls: [], options, values })
8794
},
8895
}
8996
}
9097

91-
const behaviorMatches = <TArgs extends unknown[]>(actualArguments: TArgs) => {
92-
return (behavior: BehaviorEntry<TArgs>): boolean => {
98+
const behaviorMatches = (actualArguments: unknown[]) => {
99+
return (behavior: BehaviorEntry<unknown[]>): boolean => {
93100
const { callCount, options } = behavior
94101
const { times } = options
95102

@@ -108,3 +115,11 @@ const behaviorMatches = <TArgs extends unknown[]>(actualArguments: TArgs) => {
108115
})
109116
}
110117
}
118+
119+
const getCurrentStubbedValue = (
120+
behavior: BehaviorEntry<unknown[]>,
121+
): unknown => {
122+
const { callCount, values } = behavior
123+
const hasMoreValues = callCount < values.length
124+
return hasMoreValues ? values[callCount] : values.at(-1)
125+
}

src/callback.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { expect } from 'vitest'
2+
3+
import type { BehaviorEntry } from './behaviors.ts'
4+
import type { AnyFunction } from './types.ts'
5+
6+
const CALLBACK_KEY = Symbol.for('vitest-when:callback-args')
7+
8+
export interface WithCallbackMarker {
9+
[CALLBACK_KEY]: unknown[]
10+
}
11+
12+
declare module 'vitest' {
13+
interface ExpectStatic {
14+
callback: (...args: unknown[]) => WithCallbackMarker
15+
}
16+
}
17+
18+
/**
19+
* Creates a matcher that matches a callback function. Passing no arguments
20+
* creates an empty callback matcher, in which case the arguments will be picked
21+
* up from the call to `.thenCallback()`.
22+
*/
23+
export function createCallbackMatcher(...args: unknown[]) {
24+
const matcher = expect.any(Function) as WithCallbackMarker
25+
matcher[CALLBACK_KEY] = args
26+
return matcher
27+
}
28+
29+
expect.callback = createCallbackMatcher
30+
31+
export function isCallback(value: unknown): value is WithCallbackMarker {
32+
return (
33+
typeof value === 'object' &&
34+
value !== null &&
35+
CALLBACK_KEY in value &&
36+
value[CALLBACK_KEY] !== undefined
37+
)
38+
}
39+
40+
/**
41+
* Append an empty callback matcher if one isn't already present.
42+
*/
43+
export function concatImpliedCallback<TArgs extends unknown[]>(
44+
args: TArgs,
45+
): TArgs {
46+
const callbackArguments = args.filter(isCallback)
47+
48+
// The user didn't provide a callback matcher, so we'll add one
49+
if (callbackArguments.length === 0)
50+
return [...args, expect.callback()] as TArgs
51+
52+
// The user provided one or more callback matchers, and at least one of them
53+
// is the special empty one.
54+
for (const callbackArgument of callbackArguments) {
55+
const stubbedArgs = callbackArgument[CALLBACK_KEY]
56+
if (stubbedArgs.length === 0) return args
57+
}
58+
59+
// The user provided one or more callback matchers, but none of them are the
60+
// special empty one, so we'll add one.
61+
return [...args, expect.callback()] as TArgs
62+
}
63+
64+
/**
65+
* Invokes any callbacks that were stubbed for the given behavior.
66+
*/
67+
export const invokeCallbackFor = (
68+
behavior: BehaviorEntry<unknown[]>,
69+
actualArguments: unknown[],
70+
) => {
71+
for (const [index, expectedArgument] of behavior.args.entries()) {
72+
if (!isCallback(expectedArgument)) continue
73+
74+
// Callback here is guaranteed to be a function due to the matcher
75+
// validation in `createCallbackMatcher()`, so we can cast it as such.
76+
const callback = actualArguments[index] as AnyFunction
77+
78+
// If this is the empty callback matcher, then we'll use the values passed
79+
// to `.thenCallback()` as the arguments.
80+
const callbackArguments =
81+
expectedArgument[CALLBACK_KEY].length === 0
82+
? behavior.values
83+
: expectedArgument[CALLBACK_KEY]
84+
85+
// Defer the callback to the next tick to ensure correct async behavior as
86+
// well as protect the core from any userland errors.
87+
setImmediate(callback, ...callbackArguments)
88+
}
89+
}
90+
91+
/**
92+
* Returns a Promise that resolves on the next tick. When testing callbacks, all
93+
* invoked callbacks are deferred until the next tick, so you must `await
94+
* nextTick()` before asserting on the callback arguments.
95+
*/
96+
export const nextTick = () =>
97+
new Promise<void>((resolve) => setImmediate(resolve))

src/debug.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ import type { StubbingPlan } from './behaviors.ts'
88
import { getBehaviorStack } from './stubs.ts'
99
import type { MockInstance } from './types.ts'
1010

11-
interface Behavior {
12-
type: StubbingPlan
13-
values: readonly unknown[]
14-
}
15-
1611
export interface DebugResult {
1712
name: string
1813
description: string

src/stubs.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,11 @@ export const configureMock = <TFunc extends AnyMockable>(
2929
const behaviorStack = createBehaviorStack<TFunc>()
3030
const fallbackImplementation = getFallbackImplementation(mock)
3131

32-
function implementation(this: ThisType<TFunc>, ...args: ParametersOf<TFunc>) {
33-
const { callCount, plan, values } = behaviorStack.use(
34-
args,
35-
fallbackImplementation,
36-
)
37-
const hasMoreValues = callCount < values.length
38-
39-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
40-
const value: unknown = hasMoreValues ? values[callCount] : values.at(-1)
32+
function implementation(
33+
this: ThisType<TFunc>,
34+
...args: ParametersOf<TFunc>
35+
): unknown {
36+
const { plan, value } = behaviorStack.use(args, fallbackImplementation)
4137

4238
switch (plan) {
4339
case 'thenReturn': {
@@ -54,11 +50,15 @@ export const configureMock = <TFunc extends AnyMockable>(
5450
return Promise.reject(value)
5551
}
5652
case 'thenDo': {
57-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
5853
return typeof value === 'function'
5954
? value.call(this, ...args)
6055
: undefined
6156
}
57+
case 'thenCallback': {
58+
// Callback is called during `behaviorStack.use()` so there's nothing to
59+
// do here.
60+
return undefined
61+
}
6262
}
6363
}
6464

src/vitest-when.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import type {
1313
} from './types.ts'
1414

1515
export type { WhenOptions } from './behaviors.ts'
16+
export {
17+
createCallbackMatcher as expectCallback,
18+
nextTick,
19+
} from './callback.ts'
1620
export type { DebugResult, Stubbing } from './debug.ts'
1721
export * from './errors.ts'
1822

@@ -28,6 +32,7 @@ export interface Stub<TFunc extends AnyMockable> {
2832
thenThrow: (...errors: unknown[]) => Mock<TFunc>
2933
thenReject: (...errors: unknown[]) => Mock<TFunc>
3034
thenDo: (...callbacks: AsFunction<TFunc>[]) => Mock<TFunc>
35+
thenCallback: (...args: unknown[]) => Mock<TFunc>
3136
}
3237

3338
export const when = <TFunc extends AnyMockable>(
@@ -76,6 +81,13 @@ export const when = <TFunc extends AnyMockable>(
7681
})
7782
return result
7883
},
84+
thenCallback: (...callbackArguments) => {
85+
behaviorStack.addStubbing(args, callbackArguments, {
86+
...options,
87+
plan: 'thenCallback',
88+
})
89+
return result
90+
},
7991
}
8092
},
8193
}

test/callback.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import * as subject from '../src/vitest-when.ts'
4+
5+
describe('vitest-when callback', () => {
6+
it('invokes an implied callback', async () => {
7+
const next = vi.fn()
8+
const spy = subject
9+
.when(vi.fn())
10+
.calledWith('hello', 'world')
11+
.thenCallback(undefined, 'okay')
12+
13+
expect(spy('hello', 'world', next)).toBeUndefined()
14+
15+
await subject.nextTick()
16+
expect(next).toHaveBeenCalledExactlyOnceWith(undefined, 'okay')
17+
})
18+
19+
it('invokes a specified callback', async () => {
20+
const next = vi.fn()
21+
const spy = subject
22+
.when(vi.fn())
23+
.calledWith('hello', expect.callback(), 'world')
24+
.thenCallback('okay')
25+
26+
spy('hello', next, 'world')
27+
28+
await subject.nextTick()
29+
expect(next).toHaveBeenCalledExactlyOnceWith('okay')
30+
})
31+
32+
it('invokes multiple specified callbacks', async () => {
33+
const next = vi.fn()
34+
const spy = subject
35+
.when(vi.fn())
36+
.calledWith(
37+
expect.callback(undefined, 'onStart'),
38+
expect.callback(undefined, 'onEnd'),
39+
)
40+
.thenReturn('okay')
41+
42+
expect(spy(next, next)).toBe('okay')
43+
44+
await subject.nextTick()
45+
expect(next).toHaveBeenCalledTimes(2)
46+
expect(next).toHaveBeenNthCalledWith(1, undefined, 'onStart')
47+
expect(next).toHaveBeenNthCalledWith(2, undefined, 'onEnd')
48+
})
49+
50+
it('appends implied callback even when specified callbacks are provided', async () => {
51+
const next = vi.fn()
52+
const spy = subject
53+
.when(vi.fn())
54+
.calledWith(expect.callback('first'))
55+
.thenCallback('second')
56+
57+
spy(next, next)
58+
59+
await subject.nextTick()
60+
expect(next).toHaveBeenCalledTimes(2)
61+
expect(next).toHaveBeenNthCalledWith(1, 'first')
62+
expect(next).toHaveBeenNthCalledWith(2, 'second')
63+
})
64+
})

test/debug.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,7 @@ describe('vitest-when debug', () => {
9090
},
9191
],
9292
unmatchedCalls: [[1234]],
93-
description: expect.stringMatching(
94-
/1 call:\n\s+- \(1234\)/,
95-
) as string,
93+
description: expect.stringMatching(/1 call:\n\s+- \(1234\)/) as string,
9694
} satisfies subject.DebugResult)
9795
})
9896

0 commit comments

Comments
 (0)