Skip to content

Commit 9e8e0da

Browse files
sirlancelotmcous
andauthored
feat: allow partial args matching with ignoreExtraArgs (#36)
Co-authored-by: Michael Cousins <[email protected]>
1 parent ff8c781 commit 9e8e0da

File tree

7 files changed

+137
-32
lines changed

7 files changed

+137
-32
lines changed

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,10 @@ expect(mock()).toBe(undefined)
205205
import type { WhenOptions } from 'vitest-when'
206206
```
207207

208-
| option | default | type | description |
209-
| ------- | ------- | ------- | -------------------------------------------------- |
210-
| `times` | N/A | integer | Only trigger configured behavior a number of times |
208+
| option | default | type | description |
209+
| ----------------- | ------- | ------- | -------------------------------------------------- |
210+
| `ignoreExtraArgs` | `false` | boolean | Ignore extra arguments when matching arguments |
211+
| `times` | N/A | integer | Only trigger configured behavior a number of times |
211212

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

@@ -306,6 +307,17 @@ expect(mock('hello')).toEqual('sup?')
306307
expect(mock('hello')).toEqual('sup?')
307308
```
308309

310+
You can also ignore extra arguments when matching arguments.
311+
312+
```ts
313+
const mock = when(vi.fn(), { ignoreExtraArgs: true })
314+
.calledWith('hello')
315+
.thenReturn('world')
316+
317+
expect(mock('hello')).toEqual('world')
318+
expect(mock('hello', 'jello')).toEqual('world')
319+
```
320+
309321
### `.thenResolve(value: TReturn) -> Mock<TFunc>`
310322

311323
When the stubbing is satisfied, resolve a `Promise` with `value`

src/behaviors.ts

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import type {
66
AsFunction,
77
ParametersOf,
88
ReturnTypeOf,
9-
WithMatchers,
109
} from './types.ts'
1110

1211
export interface WhenOptions {
12+
ignoreExtraArgs?: boolean
1313
times?: number
1414
}
1515

@@ -22,10 +22,7 @@ export interface BehaviorStack<TFunc extends AnyMockable> {
2222

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

25-
bindArgs: (
26-
args: WithMatchers<ParametersOf<TFunc>>,
27-
options: WhenOptions,
28-
) => BoundBehaviorStack<TFunc>
25+
bindArgs: (args: unknown[], options: WhenOptions) => BoundBehaviorStack<TFunc>
2926
}
3027

3128
export interface BoundBehaviorStack<TFunc extends AnyMockable> {
@@ -37,9 +34,10 @@ export interface BoundBehaviorStack<TFunc extends AnyMockable> {
3734
}
3835

3936
export interface BehaviorEntry<TArgs extends unknown[]> {
40-
args: WithMatchers<TArgs>
37+
args: unknown[]
4138
behavior: Behavior
4239
calls: TArgs[]
40+
ignoreExtraArgs: boolean
4341
maxCallCount?: number | undefined
4442
}
4543

@@ -60,6 +58,7 @@ export type Behavior =
6058

6159
export interface BehaviorOptions<TValue> {
6260
value: TValue
61+
ignoreExtraArgs: boolean
6362
maxCallCount: number | undefined
6463
}
6564

@@ -76,7 +75,7 @@ export const createBehaviorStack = <
7675

7776
use: (args) => {
7877
const behavior = behaviors
79-
.filter((b) => behaviorAvailable(b))
78+
.filter(behaviorAvailable)
8079
.find(behaviorMatches(args))
8180

8281
if (!behavior) {
@@ -92,8 +91,9 @@ export const createBehaviorStack = <
9291
addReturn: (values) => {
9392
behaviors.unshift(
9493
...getBehaviorOptions(values, options).map(
95-
({ value, maxCallCount }) => ({
94+
({ value, ignoreExtraArgs, maxCallCount }) => ({
9695
args,
96+
ignoreExtraArgs,
9797
maxCallCount,
9898
behavior: { type: BehaviorType.RETURN, value },
9999
calls: [],
@@ -104,8 +104,9 @@ export const createBehaviorStack = <
104104
addResolve: (values) => {
105105
behaviors.unshift(
106106
...getBehaviorOptions(values, options).map(
107-
({ value, maxCallCount }) => ({
107+
({ value, ignoreExtraArgs, maxCallCount }) => ({
108108
args,
109+
ignoreExtraArgs,
109110
maxCallCount,
110111
behavior: { type: BehaviorType.RESOLVE, value },
111112
calls: [],
@@ -116,8 +117,9 @@ export const createBehaviorStack = <
116117
addThrow: (values) => {
117118
behaviors.unshift(
118119
...getBehaviorOptions(values, options).map(
119-
({ value, maxCallCount }) => ({
120+
({ value, ignoreExtraArgs, maxCallCount }) => ({
120121
args,
122+
ignoreExtraArgs,
121123
maxCallCount,
122124
behavior: { type: BehaviorType.THROW, error: value },
123125
calls: [],
@@ -128,8 +130,9 @@ export const createBehaviorStack = <
128130
addReject: (values) => {
129131
behaviors.unshift(
130132
...getBehaviorOptions(values, options).map(
131-
({ value, maxCallCount }) => ({
133+
({ value, ignoreExtraArgs, maxCallCount }) => ({
132134
args,
135+
ignoreExtraArgs,
133136
maxCallCount,
134137
behavior: { type: BehaviorType.REJECT, error: value },
135138
calls: [],
@@ -140,8 +143,9 @@ export const createBehaviorStack = <
140143
addDo: (values) => {
141144
behaviors.unshift(
142145
...getBehaviorOptions(values, options).map(
143-
({ value, maxCallCount }) => ({
146+
({ value, ignoreExtraArgs, maxCallCount }) => ({
144147
args,
148+
ignoreExtraArgs,
145149
maxCallCount,
146150
behavior: {
147151
type: BehaviorType.DO,
@@ -158,14 +162,15 @@ export const createBehaviorStack = <
158162

159163
const getBehaviorOptions = <TValue>(
160164
values: TValue[],
161-
{ times }: WhenOptions,
165+
{ ignoreExtraArgs, times }: WhenOptions,
162166
): BehaviorOptions<TValue>[] => {
163167
if (values.length === 0) {
164168
values = [undefined as TValue]
165169
}
166170

167171
return values.map((value, index) => ({
168172
value,
173+
ignoreExtraArgs: ignoreExtraArgs ?? false,
169174
maxCallCount: times ?? (index < values.length - 1 ? 1 : undefined),
170175
}))
171176
}
@@ -179,18 +184,19 @@ const behaviorAvailable = <TArgs extends unknown[]>(
179184
)
180185
}
181186

182-
const behaviorMatches = <TArgs extends unknown[]>(args: TArgs) => {
183-
return (behavior: BehaviorEntry<TArgs>): boolean => {
184-
let index = 0
187+
const behaviorMatches = <TArgs extends unknown[]>(actualArgs: TArgs) => {
188+
return (behaviorEntry: BehaviorEntry<TArgs>): boolean => {
189+
const { args: expectedArgs, ignoreExtraArgs } = behaviorEntry
190+
const isArgsLengthMatch = ignoreExtraArgs
191+
? expectedArgs.length <= actualArgs.length
192+
: expectedArgs.length === actualArgs.length
185193

186-
while (index < args.length || index < behavior.args.length) {
187-
if (!equals(args[index], behavior.args[index])) {
188-
return false
189-
}
190-
191-
index += 1
194+
if (!isArgsLengthMatch) {
195+
return false
192196
}
193197

194-
return true
198+
return expectedArgs.every((expected, index) =>
199+
equals(actualArgs[index], expected),
200+
)
195201
}
196202
}

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ export type ParametersOf<TFunc extends AnyMockable> =
4949
? Parameters<TFunc>
5050
: never
5151

52+
/** An arguments list, optionally without every argument specified */
53+
export type ArgumentsSpec<
54+
TArgs extends any[],
55+
TOptions extends { ignoreExtraArgs?: boolean } | undefined,
56+
> = TOptions extends { ignoreExtraArgs: true }
57+
? TArgs extends [infer Head, ...infer Tail]
58+
? [] | [Head] | [Head, ...ArgumentsSpec<Tail, TOptions>]
59+
: TArgs
60+
: TArgs
61+
5262
/** Extract return type from either a function or constructor */
5363
export type ReturnTypeOf<TFunc extends AnyMockable> =
5464
TFunc extends AnyConstructor

src/vitest-when.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type DebugResult, getDebug } from './debug.ts'
33
import { asMock, configureMock, validateMock } from './stubs.ts'
44
import type {
55
AnyMockable,
6+
ArgumentsSpec,
67
AsFunction,
78
Mock,
89
MockInstance,
@@ -16,8 +17,11 @@ export { type Behavior, BehaviorType, type WhenOptions } from './behaviors.ts'
1617
export type { DebugResult, Stubbing } from './debug.ts'
1718
export * from './errors.ts'
1819

19-
export interface StubWrapper<TFunc extends AnyMockable> {
20-
calledWith<TArgs extends ParametersOf<TFunc>>(
20+
export interface StubWrapper<
21+
TFunc extends AnyMockable,
22+
TOptions extends WhenOptions | undefined,
23+
> {
24+
calledWith<TArgs extends ArgumentsSpec<ParametersOf<TFunc>, TOptions>>(
2125
...args: WithMatchers<TArgs>
2226
): Stub<TFunc>
2327
}
@@ -30,17 +34,20 @@ export interface Stub<TFunc extends AnyMockable> {
3034
thenDo: (...callbacks: AsFunction<TFunc>[]) => Mock<TFunc>
3135
}
3236

33-
export const when = <TFunc extends AnyMockable>(
37+
export const when = <
38+
TFunc extends AnyMockable,
39+
TOptions extends WhenOptions | undefined = undefined,
40+
>(
3441
mock: TFunc | MockInstance<TFunc>,
35-
options: WhenOptions = {},
36-
): StubWrapper<NormalizeMockable<TFunc>> => {
42+
options?: TOptions,
43+
): StubWrapper<NormalizeMockable<TFunc>, TOptions> => {
3744
const validatedMock = validateMock(mock)
3845
const behaviorStack = configureMock(validatedMock)
3946
const result = asMock(validatedMock)
4047

4148
return {
4249
calledWith: (...args) => {
43-
const behaviors = behaviorStack.bindArgs(args, options)
50+
const behaviors = behaviorStack.bindArgs(args as unknown[], options ?? {})
4451

4552
return {
4653
thenReturn: (...values) => {

test/fixtures.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export async function simpleAsync(input: number): Promise<string> {
1717
throw new Error(`simpleAsync(${input})`)
1818
}
1919

20+
export function multipleArgs(a: number, b: string, c: boolean): string {
21+
throw new Error(`multipleArgs(${a}, ${b}, ${c})`)
22+
}
23+
2024
export function complex(input: { a: number; b: string }): string {
2125
throw new Error(`simple({ a: ${input.a}, b: ${input.b} })`)
2226
}

test/typing.test-d.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as subject from '../src/vitest-when.ts'
1717
import {
1818
complex,
1919
generic,
20+
multipleArgs,
2021
overloaded,
2122
simple,
2223
simpleAsync,
@@ -45,6 +46,37 @@ describe('vitest-when type signatures', () => {
4546
>()
4647
})
4748

49+
it('should handle fewer than required arguments', () => {
50+
subject.when(multipleArgs, { ignoreExtraArgs: true }).calledWith(42)
51+
52+
subject
53+
.when(multipleArgs, { ignoreExtraArgs: true })
54+
.calledWith(42, 'hello')
55+
56+
subject
57+
.when(multipleArgs, { ignoreExtraArgs: true })
58+
.calledWith(42, 'hello', true)
59+
60+
subject
61+
.when(multipleArgs, { ignoreExtraArgs: true })
62+
// @ts-expect-error: too many arguments
63+
.calledWith(42, 'hello', true, 'oh no')
64+
})
65+
66+
it('supports using matchers with ignoreExtraArgs', () => {
67+
subject
68+
.when(multipleArgs, { ignoreExtraArgs: true })
69+
.calledWith(expect.any(Number))
70+
71+
subject
72+
.when(multipleArgs, { ignoreExtraArgs: true })
73+
.calledWith(expect.any(Number), expect.any(String))
74+
75+
subject
76+
.when(multipleArgs, { ignoreExtraArgs: true })
77+
.calledWith(expect.any(Number), expect.any(String), expect.any(Boolean))
78+
})
79+
4880
it('returns mock type for then resolve', () => {
4981
const result = subject.when(simpleAsync).calledWith(1).thenResolve('hello')
5082

test/vitest-when.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,38 @@ describe('vitest-when', () => {
279279
// intentionally do not call the spy
280280
expect(true).toBe(true)
281281
})
282+
283+
it.each([
284+
{ stubArgs: [] as unknown[], callArgs: [] as unknown[] },
285+
{ stubArgs: [], callArgs: ['a'] },
286+
{ stubArgs: [], callArgs: ['a', 'b'] },
287+
{ stubArgs: ['a'], callArgs: ['a'] },
288+
{ stubArgs: ['a'], callArgs: ['a', 'b'] },
289+
{ stubArgs: ['a', 'b'], callArgs: ['a', 'b'] },
290+
])(
291+
'matches call $callArgs against stub $stubArgs args with ignoreExtraArgs',
292+
({ stubArgs, callArgs }) => {
293+
const spy = subject
294+
.when(vi.fn().mockReturnValue('failure'), { ignoreExtraArgs: true })
295+
.calledWith(...stubArgs)
296+
.thenReturn('success')
297+
298+
expect(spy(...callArgs)).toEqual('success')
299+
},
300+
)
301+
302+
it.each([
303+
{ stubArgs: ['a'] as unknown[], callArgs: ['b'] as unknown[] },
304+
{ stubArgs: [undefined], callArgs: [] },
305+
])(
306+
'does not match call $callArgs against stub $stubArgs with ignoreExtraArgs',
307+
({ stubArgs, callArgs }) => {
308+
const spy = subject
309+
.when(vi.fn().mockReturnValue('success'), { ignoreExtraArgs: true })
310+
.calledWith(...stubArgs)
311+
.thenReturn('failure')
312+
313+
expect(spy(...callArgs)).toBe('success')
314+
},
315+
)
282316
})

0 commit comments

Comments
 (0)