Skip to content

Commit 616e8da

Browse files
authored
fix: support typing overloaded functions (#1)
1 parent dd1392b commit 616e8da

10 files changed

+477
-172
lines changed

.lintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ coverage
22
dist
33
node_modules
44
pnpm-lock.yaml
5+
tsconfig.vitest-temp.json

README.md

Lines changed: 114 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![ci badge][]][ci]
55
[![coverage badge][]][coverage]
66

7-
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][].
7+
Stub behaviors of [Vitest][] mock functions with a small, readable API. Inspired by [testdouble.js][] and [jest-when][].
88

99
```shell
1010
npm install --save-dev vitest-when
@@ -20,28 +20,100 @@ npm install --save-dev vitest-when
2020
[coverage]: https://coveralls.io/github/mcous/vitest-when
2121
[coverage badge]: https://img.shields.io/coverallsCoverage/github/mcous/vitest-when?style=flat-square
2222

23-
## Why?
23+
## Usage
2424

25-
[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.
25+
Create [stubs][] - fake objects that have pre-configured responses to matching arguments - from [Vitest's mock functions][]. With vitest-when, your stubs are:
2626

27-
- It's easy to make silly mistakes, like mocking a return value without checking the arguments.
28-
- 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.
27+
- Easy to read
28+
- Hard to misconfigure, especially when using TypeScript
2929

30-
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.
30+
Wrap your `vi.fn()` mock - or a function imported from a `vi.mock`'d module - in [`when`][when], match on a set of arguments using [`calledWith`][called-with], and configure a behavior
3131

32-
[vitest mock functions]: https://vitest.dev/api/mock.html#mockreset
33-
[arrange and assert]: https://github.com/testdouble/contributing-tests/wiki/Arrange-Act-Assert
32+
- [`.thenReturn()`][then-return] - Return a value
33+
- [`.thenResolve()`][then-resolve] - Resolve a `Promise`
34+
- [`.thenThrow()`][then-throw] - Throw an error
35+
- [`.thenReject()`][then-reject] - Reject a `Promise`
36+
- [`.thenDo()`][then-do] - Trigger a function
3437

35-
## Usage
38+
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`.
39+
40+
```ts
41+
import { vi, test, afterEach } from 'vitest';
42+
import { when } from '';
43+
44+
afterEach(() => {
45+
vi.resetAllMocks();
46+
});
47+
48+
test('stubbing with vitest-when', () => {
49+
const stub = vi.fn();
50+
51+
when(stub).calledWith(1, 2, 3).thenReturn(4);
52+
when(stub).calledWith(4, 5, 6).thenReturn(7);
53+
54+
const result123 = stub(1, 2, 3);
55+
expect(result).toBe(4);
56+
57+
const result456 = stub(4, 5, 6);
58+
expect(result).toBe(7);
59+
60+
const result789 = stub(7, 8, 9);
61+
expect(result).toBe(undefined);
62+
});
63+
```
64+
65+
You should call `vi.resetAllMocks()` in your suite's `afterEach` hook to remove the implementation added by `when`. You can also set Vitest's [`mockReset`](https://vitest.dev/config/#mockreset) config to `true` instead of using `afterEach`.
66+
67+
[vitest's mock functions]: https://vitest.dev/api/mock.html
68+
[stubs]: https://en.wikipedia.org/wiki/Test_stub
69+
[when]: #whenspy-tfunc-stubwrappertfunc
70+
[called-with]: #calledwithargs-targs-stubtargs-treturn
71+
[then-return]: #thenreturnvalue-treturn
72+
[then-resolve]: #thenresolvevalue-treturn
73+
[then-throw]: #thenthrowerror-unknown
74+
[then-reject]: #thenrejecterror-unknown
75+
[then-do]: #thendocallback-args-targs--treturn
76+
77+
### Why not vanilla Vitest mocks?
78+
79+
Vitest's mock functions are powerful, but have an overly permissive API, inherited from Jest. Vanilla `vi.fn()` mock functions are difficult to use well and easy to use poorly.
80+
81+
- Mock usage is spread across the [arrange and assert][] phases of your test, with "act" in between, making the test harder to read.
82+
- If you forget the `expect(...).toHaveBeenCalledWith(...)` step, the test will pass even if the mock is called incorrectly.
83+
- `expect(...).toHaveBeenCalledWith(...)` is not type-checked, as of Vitest `0.31.0`.
84+
85+
```ts
86+
// arrange
87+
const stub = vi.fn();
88+
stub.mockReturnValue('world');
89+
90+
// act
91+
const result = stub('hello');
92+
93+
// assert
94+
expect(stub).toHaveBeenCalledWith('hello');
95+
expect(result).toBe('world');
96+
```
97+
98+
In contrast, when using vitest-when stubs:
3699

37-
0. Add `vi.resetAllMocks` to your suite's `afterEach` hook
38-
1. Use `when(mock).calledWith(...)` to specify matching arguments
39-
2. Configure a behavior with a stub method:
40-
- Return a value: `.thenReturn(...)`
41-
- Resolve a `Promise`: `.thenResolve(...)`
42-
- Throw an error: `.thenThrow(...)`
43-
- Reject a `Promise`: `.thenReject(...)`
44-
- Trigger a callback: `.thenDo(...)`
100+
- All stub configuration happens in the "arrange" phase of your test.
101+
- You cannot forget `calledWith`.
102+
- `calledWith` and `thenReturn` (et. al.) are fully type-checked.
103+
104+
```ts
105+
// arrange
106+
const stub = vi.fn();
107+
when(stub).calledWith('hello').thenReturn('world');
108+
109+
// act
110+
const result = stub('hello');
111+
112+
// assert
113+
expect(result).toBe('world');
114+
```
115+
116+
[arrange and assert]: https://github.com/testdouble/contributing-tests/wiki/Arrange-Act-Assert
45117

46118
### Example
47119

@@ -59,12 +131,12 @@ import * as subject from './meaning-of-life.ts';
59131
vi.mock('./deep-thought.ts');
60132
vi.mock('./earth.ts');
61133

62-
describe('subject under test', () => {
134+
describe('get the meaning of life', () => {
63135
afterEach(() => {
64136
vi.resetAllMocks();
65137
});
66138

67-
it('should delegate work to dependency', async () => {
139+
it('should get the answer and the question', async () => {
68140
when(deepThought.calculateAnswer).calledWith().thenResolve(42);
69141
when(earth.calculateQuestion).calledWith(42).thenResolve("What's 6 by 9?");
70142

@@ -73,7 +145,9 @@ describe('subject under test', () => {
73145
expect(result).toEqual({ question: "What's 6 by 9?", answer: 42 });
74146
});
75147
});
148+
```
76149

150+
```ts
77151
// meaning-of-life.ts
78152
import { calculateAnswer } from './deep-thought.ts';
79153
import { calculateQuestion } from './earth.ts';
@@ -89,12 +163,16 @@ export const createMeaning = async (): Promise<Meaning> => {
89163

90164
return { question, answer };
91165
};
166+
```
92167

168+
```ts
93169
// deep-thought.ts
94170
export const calculateAnswer = async (): Promise<number> => {
95171
throw new Error(`calculateAnswer() not implemented`);
96172
};
173+
```
97174

175+
```ts
98176
// earth.ts
99177
export const calculateQuestion = async (answer: number): Promise<string> => {
100178
throw new Error(`calculateQuestion(${answer}) not implemented`);
@@ -103,19 +181,32 @@ export const calculateQuestion = async (answer: number): Promise<string> => {
103181

104182
## API
105183

106-
### `when(spy: Mock<TArgs, TReturn>).calledWith(...args: TArgs): Stub<TArgs, TReturn>`
184+
### `when(spy: TFunc): StubWrapper<TFunc>`
107185

108-
Create's a stub for a given set of arguments that you can then configure with different behaviors.
186+
Configures a `vi.fn()` mock function to act as a vitest-when stub. Adds an implementation to the function that initially no-ops, and returns an API to configure behaviors for given arguments using [`.calledWith(...)`][called-with]
109187

110188
```ts
189+
import { vi } from 'vitest';
190+
import { when } from 'vitest-when';
191+
111192
const spy = vi.fn();
193+
const stubWrapper = when(spy);
112194

113-
when(spy).calledWith('hello').thenReturn('world');
195+
expect(spy()).toBe(undefined);
196+
```
197+
198+
### `.calledWith(...args: TArgs): Stub<TArgs, TReturn>`
199+
200+
Create a stub that matches a given set of arguments which you can configure with different behaviors using methods like [`.thenReturn(...)`][then-return].
201+
202+
```ts
203+
const spy = vi.fn();
204+
const stub = when(spy).calledWith('hello').thenReturn('world');
114205

115206
expect(spy('hello')).toEqual('world');
116207
```
117208

118-
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:
209+
When a call to a mock uses arguments that match those given to `calledWith`, a configured behavior will be triggered. All arguments must match, but you can use Vitest's [asymmetric matchers][] to loosen the stubbing:
119210

120211
```ts
121212
const spy = vi.fn();
@@ -338,10 +429,3 @@ when(spy)
338429
expect(spy('hello')).toEqual('world');
339430
expect(spy('hello')).toEqual('solar system');
340431
```
341-
342-
## See also
343-
344-
- [testdouble-vitest][] - Use [testdouble.js][] mocks with Vitest instead of the default [tinyspy][] mocks.
345-
346-
[testdouble-vitest]: https://github.com/mcous/testdouble-vitest
347-
[tinyspy]: https://github.com/tinylibs/tinyspy

example/meaning-of-life.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import * as subject from './meaning-of-life.ts';
88
vi.mock('./deep-thought.ts');
99
vi.mock('./earth.ts');
1010

11-
describe('subject under test', () => {
11+
describe('get the meaning of life', () => {
1212
afterEach(() => {
1313
vi.resetAllMocks();
1414
});
1515

16-
it('should delegate work to dependency', async () => {
16+
it('should get the answer and the question', async () => {
1717
when(deepThought.calculateAnswer).calledWith().thenResolve(42);
1818
when(earth.calculateQuestion).calledWith(42).thenResolve("What's 6 by 9?");
1919

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vitest-when",
33
"version": "0.1.1",
4-
"description": "Stub behaviors of vitest mocks based on how they are called",
4+
"description": "Stub behaviors of Vitest mock functions with a small, readable API.",
55
"type": "module",
66
"exports": {
77
".": {
@@ -19,7 +19,7 @@
1919
"access": "public",
2020
"provenance": true
2121
},
22-
"packageManager": "pnpm@8.5.0",
22+
"packageManager": "pnpm@8.5.1",
2323
"author": "Michael Cousins <michael@cousins.io> (https://mike.cousins.io)",
2424
"license": "MIT",
2525
"repository": {
@@ -43,6 +43,7 @@
4343
"coverage": "vitest run --coverage",
4444
"check:format": "pnpm run _prettier --check",
4545
"check:lint": "pnpm run _eslint",
46+
"check:types": "vitest typecheck --run",
4647
"format": "pnpm run _prettier --write && pnpm run _eslint --fix",
4748
"_eslint": "eslint --ignore-path .lintignore \"**/*.ts\"",
4849
"_prettier": "prettier --ignore-path .lintignore \"**/*.@(ts|json|yaml)\""

0 commit comments

Comments
 (0)