Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,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 |
| ----------------- | ------- | ------- | -------------------------------------------------- |
| `ignoreExtraArgs` | `false` | boolean | Ignore extra arguments when matching arguments |
| `times` | N/A | integer | Only trigger configured behavior a number of times |

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

Expand Down Expand Up @@ -306,6 +307,17 @@ expect(mock('hello')).toEqual('sup?')
expect(mock('hello')).toEqual('sup?')
```

You can also ignore extra arguments when matching arguments.

```ts
const mock = when(vi.fn(), { ignoreExtraArgs: true })
.calledWith('hello')
.thenReturn('world')

expect(mock('hello')).toEqual('world')
expect(mock('hello', 'jello')).toEqual('world')
```

### `.thenResolve(value: TReturn) -> Mock<TFunc>`

When the stubbing is satisfied, resolve a `Promise` with `value`
Expand Down
44 changes: 26 additions & 18 deletions src/behaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from './types.ts'

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

Expand Down Expand Up @@ -40,6 +41,7 @@ export interface BehaviorEntry<TArgs extends unknown[]> {
args: WithMatchers<TArgs>
behavior: Behavior
calls: TArgs[]
ignoreExtraArgs: boolean
maxCallCount?: number | undefined
}

Expand All @@ -60,6 +62,7 @@ export type Behavior =

export interface BehaviorOptions<TValue> {
value: TValue
ignoreExtraArgs: boolean
maxCallCount: number | undefined
}

Expand Down Expand Up @@ -92,8 +95,9 @@ export const createBehaviorStack = <
addReturn: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
({ value, ignoreExtraArgs, maxCallCount }) => ({
args,
ignoreExtraArgs,
maxCallCount,
behavior: { type: BehaviorType.RETURN, value },
calls: [],
Expand All @@ -104,8 +108,9 @@ export const createBehaviorStack = <
addResolve: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
({ value, ignoreExtraArgs, maxCallCount }) => ({
args,
ignoreExtraArgs,
maxCallCount,
behavior: { type: BehaviorType.RESOLVE, value },
calls: [],
Expand All @@ -116,8 +121,9 @@ export const createBehaviorStack = <
addThrow: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
({ value, ignoreExtraArgs, maxCallCount }) => ({
args,
ignoreExtraArgs,
maxCallCount,
behavior: { type: BehaviorType.THROW, error: value },
calls: [],
Expand All @@ -128,8 +134,9 @@ export const createBehaviorStack = <
addReject: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
({ value, ignoreExtraArgs, maxCallCount }) => ({
args,
ignoreExtraArgs,
maxCallCount,
behavior: { type: BehaviorType.REJECT, error: value },
calls: [],
Expand All @@ -140,8 +147,9 @@ export const createBehaviorStack = <
addDo: (values) => {
behaviors.unshift(
...getBehaviorOptions(values, options).map(
({ value, maxCallCount }) => ({
({ value, ignoreExtraArgs, maxCallCount }) => ({
args,
ignoreExtraArgs,
maxCallCount,
behavior: {
type: BehaviorType.DO,
Expand All @@ -158,14 +166,15 @@ export const createBehaviorStack = <

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

return values.map((value, index) => ({
value,
ignoreExtraArgs: ignoreExtraArgs ?? false,
maxCallCount: times ?? (index < values.length - 1 ? 1 : undefined),
}))
}
Expand All @@ -179,18 +188,17 @@ const behaviorAvailable = <TArgs extends unknown[]>(
)
}

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

while (index < args.length || index < behavior.args.length) {
if (!equals(args[index], behavior.args[index])) {
return false
}

index += 1
}

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

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

@mcous mcous Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this arity check before the arguments - it makes me realize there's a subtle bug in the current implementation around explicit vs implicit undefined values

A few suggestions, in priority order

  • Should the check be > in the ignoreExtraArgs case?
  • I don't think these comments add any extra value to the code, let's lose them in favor of variable names
Suggested change
// Check arity
const expectedArguments = behavior.args
const { ignoreExtraArgs } = behavior
if (expectedArguments.length !== actualArguments.length && !ignoreExtraArgs)
return false
// Check arguments
return expectedArguments.every((expectedArgument, index) => {
return equals(actualArguments[index], expectedArgument)
})
const { args: expectedArguments, ignoreExtraArgs } = behavior
const isMatchingArgumentCount = ignoreExtraArgs
? expectedArguments.length <= actualArguments.length
: expectedArguments.length === actualArguments.length
if (!isMatchingArgumentCount) {
return false
}
return expectedArguments.every((expectedArgument, index) => {
return equals(actualArguments[index], expectedArgument)
})

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expectedArguments length is defined by the stub the user created with .calledWith(). This rehearsal is expected to be "correct", and so actualArguments length should exactly match in order for the stubbing to be satisfied, unless ignoreExtraArgs is true.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the following specification of ignoreExtraArgs...

Check all arguments in calledWith to find a match, but past the arguments specified in calledWith, ignore any extra arguments in the call itself when determining the match

...withignoreExtraArgs set, what do you expect the following stubbing to do?

const spy = when(vi.fn(), { ignoreExtraArgs: true })
  .calledWith(undefined)
  .thenReturn('called')

const result = spy() // should this be `called` or `undefined`?

My gut says when(vi.fn(), { ignoreExtraArgs: true }).calledWith(undefined) should not match spy(). However, with a !== check, this does match.

Passing undefined explicitly is extremely similar to, but not technically exactly the same as, omitting an argument, so I think if the user says "I expect an explicit undefined" it should be respected, even if ignoreExtraArgs is set

}
}
24 changes: 24 additions & 0 deletions test/vitest-when.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,28 @@ describe('vitest-when', () => {
// intentionally do not call the spy
expect(true).toBe(true)
})

it('should ignore extra args if configured', () => {
const spy = subject
.when(vi.fn(), { ignoreExtraArgs: true })
.calledWith('Outcomes are:')
.thenReturn('loggy')

expect(spy('Outcomes are:')).toEqual('loggy')
expect(spy('Outcomes are:', 'stuff')).toEqual('loggy')
expect(spy('Outcomes are:', 'stuff', 'that', 'keeps', 'going')).toEqual(
'loggy',
)
expect(spy('Outcomes are not:', 'stuff')).toEqual(undefined)
})

it('should ignore all args if configured', () => {
const spy = subject
.when(vi.fn(), { ignoreExtraArgs: true })
.calledWith()
.thenReturn('yesss')

expect(spy()).toEqual('yesss')
expect(spy(1, 2, 3, 4, 5)).toEqual('yesss')
})
})