Stub behaviors of vitest mocks based on how they are called with a small, readable, and opinionated API. Inspired by testdouble.js and jest-when.
npm install --save-dev vitest-when
Vitest mock functions are powerful, but have an overly permissive API, inherited from Jest. This API makes it hard to use mocks to their full potential of providing meaningful design feedback while writing tests.
- It's easy to make silly mistakes, like mocking a return value without checking the arguments.
- Mock usage requires calls in both the arrange and assert phases a test (e.g. configure return value, assert called with proper arguments), which harms test readability and maintainability.
To avoid these issues, vitest-when wraps vitest mocks in a focused, opinionated API that allows you to configure mock behaviors if and only if they are called as you expect.
- Add
vi.resetAllMocks
to your suite'safterEach
hook - Use
when(mock).calledWith(...)
to specify matching arguments - Configure a behavior with a stub method:
- Return a value:
.thenReturn(...)
- Resolve a
Promise
:.thenResolve(...)
- Throw an error:
.thenThrow(...)
- Reject a
Promise
:.thenReject(...)
- Trigger a callback:
.thenDo(...)
- Return a value:
See the ./example directory for example usage.
// meaning-of-life.test.ts
import { vi, describe, afterEach, it, expect } from 'vitest';
import { when } from '../src/vitest-when.ts';
import * as deepThought from './deep-thought.ts';
import * as earth from './earth.ts';
import * as subject from './meaning-of-life.ts';
vi.mock('./deep-thought.ts');
vi.mock('./earth.ts');
describe('subject under test', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('should delegate work to dependency', async () => {
when(deepThought.calculateAnswer).calledWith().thenResolve(42);
when(earth.calculateQuestion).calledWith(42).thenResolve("What's 6 by 9?");
const result = await subject.createMeaning();
expect(result).toEqual({ question: "What's 6 by 9?", answer: 42 });
});
});
// meaning-of-life.ts
import { calculateAnswer } from './deep-thought.ts';
import { calculateQuestion } from './earth.ts';
export interface Meaning {
question: string;
answer: number;
}
export const createMeaning = async (): Promise<Meaning> => {
const answer = await calculateAnswer();
const question = await calculateQuestion(answer);
return { question, answer };
};
// deep-thought.ts
export const calculateAnswer = async (): Promise<number> => {
throw new Error(`calculateAnswer() not implemented`);
};
// earth.ts
export const calculateQuestion = async (answer: number): Promise<string> => {
throw new Error(`calculateQuestion(${answer}) not implemented`);
};
Create's a stub for a given set of arguments that you can then configure with different behaviors.
const spy = vi.fn();
when(spy).calledWith('hello').thenReturn('world');
expect(spy('hello')).toEqual('world');
When a call to a mock uses arguments that match those given to calledWith
, a configured behavior will be triggered. All arguments must match, though you can use vitest's asymmetric matchers to loosen the stubbing:
const spy = vi.fn();
when(spy).calledWith(expect.any(String)).thenReturn('world');
expect(spy('hello')).toEqual('world');
expect(spy('anything')).toEqual('world');
If calledWith
is used multiple times, the last configured stubbing will be used.
when(spy).calledWith("hello").thenReturn("world")
expect(spy("hello")).toEqual("world")
when(spy).calledWith("hello").thenReturn("goodbye"
expect(spy("hello")).toEqual("goodbye")
When the stubbing is satisfied, return value
const spy = vi.fn();
when(spy).calledWith('hello').thenReturn('world');
expect(spy('hello')).toEqual('world');
To only return a value once, use the ONCE
option.
import { ONCE, when } from 'vitest-when';
const spy = vi.fn();
when(spy).calledWith('hello').thenReturn('world', ONCE);
expect(spy('hello')).toEqual('world');
expect(spy('hello')).toEqual(undefined);
You may pass several values to thenReturn
to return different values in succession. The last value will be latched, unless you pass the ONCE
option.
const spy = vi.fn();
when(spy).calledWith('hello').thenReturn('hi', 'sup?');
expect(spy('hello')).toEqual('hi');
expect(spy('hello')).toEqual('sup?');
expect(spy('hello')).toEqual('sup?');
When the stubbing is satisfied, resolve a Promise
with value
const spy = vi.fn();
when(spy).calledWith('hello').thenResolve('world');
expect(await spy('hello')).toEqual('world');
To only resolve a value once, use the ONCE
option.
import { ONCE, when } from 'vitest-when';
const spy = vi.fn();
when(spy).calledWith('hello').thenResolve('world', ONCE);
expect(await spy('hello')).toEqual('world');
expect(spy('hello')).toEqual(undefined);
You may pass several values to thenResolve
to resolve different values in succession. The last value will be latched, unless you pass the ONCE
option.
const spy = vi.fn();
when(spy).calledWith('hello').thenResolve('hi', 'sup?');
expect(await spy('hello')).toEqual('hi');
expect(await spy('hello')).toEqual('sup?');
expect(await spy('hello')).toEqual('sup?');
When the stubbing is satisfied, throw error
.
const spy = vi.fn();
when(spy).calledWith('hello').thenThrow(new Error('oh no'));
expect(() => spy('hello')).toThrow('oh no');
To only throw an error only once, use the ONCE
option.
import { ONCE, when } from 'vitest-when';
const spy = vi.fn();
when(spy).calledWith('hello').thenThrow(new Error('oh no'), ONCE);
expect(() => spy('hello')).toThrow('oh no');
expect(spy('hello')).toEqual(undefined);
You may pass several values to thenThrow
to throw different errors in succession. The last value will be latched, unless you pass the ONCE
option.
const spy = vi.fn();
when(spy)
.calledWith('hello')
.thenThrow(new Error('oh no'), new Error('this is bad'));
expect(() => spy('hello')).toThrow('oh no');
expect(() => spy('hello')).toThrow('this is bad');
expect(() => spy('hello')).toThrow('this is bad');
When the stubbing is satisfied, reject a Promise
with error
.
const spy = vi.fn();
when(spy).calledWith('hello').thenReject(new Error('oh no'));
await expect(spy('hello')).rejects.toThrow('oh no');
To only throw an error only once, use the ONCE
option.
import { ONCE, when } from 'vitest-when';
const spy = vi.fn();
when(spy).calledWith('hello').thenReject(new Error('oh no'), ONCE);
await expect(spy('hello')).rejects.toThrow('oh no');
expect(spy('hello')).toEqual(undefined);
You may pass several values to thenReject
to throw different errors in succession. The last value will be latched, unless you pass the ONCE
option.
const spy = vi.fn();
when(spy)
.calledWith('hello')
.thenReject(new Error('oh no'), new Error('this is bad'));
await expect(spy('hello')).rejects.toThrow('oh no');
await expect(spy('hello')).rejects.toThrow('this is bad');
await expect(spy('hello')).rejects.toThrow('this is bad');
When the stubbing is satisfied, run callback
to trigger a side-effect and return its result (if any). thenDo
is a relatively powerful tool for stubbing complex behaviors, so if you find yourself using thenDo
often, consider refactoring your code to use more simple interactions! Your future self will thank you.
const spy = vi.fn();
let called = false;
when(spy)
.calledWith('hello')
.thenDo(() => {
called = true;
return 'world';
});
expect(spy('hello')).toEqual('world');
expect(called).toEqual(true);
To only run the callback once, use the ONCE
option.
import { ONCE, when } from 'vitest-when';
const spy = vi.fn();
when(spy)
.calledWith('hello')
.thenDo(() => 'world', ONCE);
expect(spy('hello')).toEqual('world');
expect(spy('hello')).toEqual(undefined);
You may pass several callbacks to thenDo
to trigger different side-effects in succession. The last callback will be latched, unless you pass the ONCE
option.
const spy = vi.fn();
when(spy)
.calledWith('hello')
.thenDo(
() => 'world',
() => 'solar system'
);
expect(spy('hello')).toEqual('world');
expect(spy('hello')).toEqual('solar system');
- testdouble-vitest - Use testdouble.js mocks with Vitest instead of the default tinyspy mocks.