Skip to content

Commit f57a716

Browse files
authored
feat: add vitest-when post (#5)
1 parent 56c40de commit f57a716

File tree

1 file changed

+245
-0
lines changed

1 file changed

+245
-0
lines changed
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
export const metadata = {
2+
title: 'Better mocks in Vitest',
3+
description: 'Introducing vitest-when: better mocking in Vitest',
4+
posted: '2023-06-30',
5+
}
6+
7+
Mocking is a fantastic tool for writing high-quality unit tests, but **nothing
8+
creates useless testing pain quite like poorly used mocks**. One type of
9+
mocking[^1] - creating and configuring stubs - is particularly useful, but hard
10+
to do well with [Vitest's][vitest] built-in [mock functions][].
11+
12+
[^1]:
13+
In case you have strong opinions about jargon: I use the word "mock" in this
14+
article to mean "[test double][]." It's less typing!
15+
16+
Inspired by a couple of my favorite testing libraries - [testdouble.js][] and
17+
[jest-when][] - I set out to write an easy-to-use stubbing library specifically
18+
for Vitest to provide focused design feedback throughout my tests.
19+
20+
Introducing: :tada: [vitest-when][] :tada:, a library that wraps Vitest mock
21+
functions so you can spend more time building and less time futzing around with
22+
mocks.
23+
24+
```shell
25+
npm install --save-dev vitest-when
26+
```
27+
28+
## What is a stub?
29+
30+
A "stub" is a fake function in a test that you configure with "canned"
31+
responses. When a stub is called with the right arguments, it returns its
32+
preconfigured response. If it's called the wrong way, it simply no-ops.
33+
34+
You can use vitest-when to configure a `vi.fn()` mock function as a stub:
35+
36+
```ts
37+
import { vi, expect, test } from 'vitest'
38+
import { when } from 'vitest-when'
39+
40+
test('stub something', () => {
41+
const sayHello = vi.fn()
42+
43+
when(sayHello).calledWith('Alice').thenReturn('Hello, Alice')
44+
when(sayHello).calledWith('Bob').thenReturn('Hey, Bob')
45+
46+
expect(sayHello('Alice')).toBe('Hello, Alice')
47+
expect(sayHello('Bob')).toBe('Hey, Bob')
48+
expect(sayHello('Carlos')).toBe(undefined)
49+
})
50+
```
51+
52+
In the above example, the `sayHello` stub behaves accordingly:
53+
54+
- When `sayHello` is called with `Alice`, it will return `Hello, Alice`
55+
- When `sayHello` is called with `Box`, it will return `Hey, Bob`
56+
- When `sayHello` is called in any other way, it will return `undefined`
57+
58+
Stubs are useful when you're mocking a function that returns data. You set up a
59+
stub that says "I'll return the correct data, but only if I get called with the
60+
right arguments", inject it into your code-under-test, and see if your code
61+
produces the correct output.
62+
63+
Functions that act upon input data to produce output data - i.e. functions that
64+
return something - are easier to reason with, assemble, and observe than
65+
functions that return nothing and solely produce side-effects. Because stubs
66+
make it easier to test your code's interaction with output-producing APIs, using
67+
stubs exerts design pressure to prefer writing functions that return something.
68+
In general, this leads to code with fewer hard-to-reason-with side-effects.
69+
70+
## What can vitest-when do?
71+
72+
You can use vitest-when to configure a stub with several behaviors.
73+
74+
```ts
75+
// Return a value
76+
when(sayHello).calledWith('Bob').thenReturn('Hello, Bob')
77+
78+
// Resolve a Promise
79+
when(sayHello).calledWith('Bob').thenResolve('Hello, Bob')
80+
81+
// Throw an error
82+
when(sayHello).calledWith('Bob').thenThrow(new Error('Bye'))
83+
84+
// Reject a Promise
85+
when(sayHello).calledWith('Bob').thenReject(new Error('Bye'))
86+
87+
// Run an arbitrary function
88+
when(sayHello)
89+
.calledWith('Bob')
90+
.thenDo((name) => `Hello, ${name}`)
91+
```
92+
93+
The same stub can have multiple different behaviors configured, depending on the
94+
arguments it's called with.
95+
96+
```ts
97+
when(sayHello).calledWith('Alice').thenReturn('Hello, Alice')
98+
when(sayHello).calledWith('Bob').thenReturn('Hello, Bob')
99+
when(sayHello).calledWith('Chad').thenThrow(new Error('Nah'))
100+
```
101+
102+
The `calledWith` method accepts Vitest's [asymmetric matchers][], so you can
103+
focus on specifying what's important without losing out on test completeness.
104+
105+
```ts
106+
when(sayHello).calledWith(expect.any(String)).thenReturn('Hello!')
107+
```
108+
109+
Finally, because vitest-when is a thin wrapper around vanilla Vitest mock
110+
functions, it's fully compatible with `vi.mock` module automocking.
111+
112+
```ts
113+
import { vi, describe, afterEach, it, expect } from 'vitest'
114+
import { when } from 'vitest-when'
115+
116+
import { getAnswer } from './deep-thought.ts'
117+
import { getQuestion } from './earth.ts'
118+
import * as subject from './meaning-of-life.ts'
119+
120+
vi.mock('./deep-thought.ts')
121+
vi.mock('./earth.ts')
122+
123+
describe('get the meaning of life', () => {
124+
it('should get the answer and the question', async () => {
125+
when(getAnswer).calledWith().thenResolve(42)
126+
when(getQuestion).calledWith(42).thenResolve("What's 6 by 9?")
127+
128+
const result = await subject.createMeaning()
129+
130+
expect(result).toEqual({
131+
question: "What's 6 by 9?",
132+
answer: 42,
133+
})
134+
})
135+
})
136+
```
137+
138+
## Why use vitest-when?
139+
140+
Vitest's built-in mock functions, returned by `vi.fn()`, are powerful and
141+
flexible. However in my experience with Vitest (and Jest before it), their API
142+
is easy to misuse, leaving you and your team with tests that are hard to read
143+
and misbehave often.
144+
145+
Take these two trivial "tests", one with vitest-when and the other without:
146+
147+
```ts
148+
import { vi, expect, test } from 'vitest'
149+
import { when } from 'vitest-when'
150+
151+
const sayHello = vi.fn()
152+
153+
test('with vitest-when', () => {
154+
when(sayHello).calledWith('Alice').thenReturn('Hello, Alice')
155+
156+
const result = sayHello('Alice')
157+
158+
expect(result).toBe('Hello, Alice')
159+
})
160+
161+
test('without vitest-when', () => {
162+
sayHello.mockReturnValue('Hello, Alice')
163+
164+
const result = sayHello('Alice')
165+
166+
expect(result).toBe('Hello, Alice')
167+
expect(sayHello).toHaveBeenCalledWith('Alice')
168+
})
169+
```
170+
171+
With vitest-when, **all configured behaviors are conditional**. You must define
172+
a complete set of arguments that will trigger the behavior, as a cause and
173+
effect:
174+
175+
```ts
176+
when(sayHello)
177+
.calledWith('Alice') // cause
178+
.thenReturn('Hello, Alice') // effect
179+
```
180+
181+
In vanilla Vitest, however, you use methods like `mockReturnValue`. These
182+
methods are unconditional; the configured behavior will trigger no matter how
183+
the mock is called, even it's called incorrectly.
184+
185+
```ts
186+
sayHello.mockReturnValue('Hello, Alice')
187+
188+
expect(sayHello('Alice')).toBe('Hello, Alice')
189+
expect(sayHello('Bob')).toBe('Hello, Alice')
190+
expect(sayHello('Carlos')).toBe('Hello, Alice')
191+
```
192+
193+
This increases the likelihood that you'll write a test that passes when it
194+
shouldn't, because the code under test might call the stub incorrectly and still
195+
receive the configured return value.
196+
197+
In order to avoid writing a test that incorrectly passes, you have to add
198+
assertion(s) near the bottom of your test that the mock was called correctly:
199+
200+
```ts
201+
sayHello.mockReturnValue('Hello, Alice') // effect
202+
203+
const result = sayHello('Alice')
204+
205+
expect(result).toBe('Hello, Alice')
206+
expect(sayHello).toHaveBeenCalledWith('Alice') // assert cause
207+
```
208+
209+
The argument assertion lives after, and away from, the return value, making the
210+
test harder to read and maintain compared to vitest-when's strategy of
211+
configuring a cause and effect.
212+
213+
You also perform an _assertion_ to check the arguments, which is a more forceful
214+
check that vitest-when's "filter by arguments and no-op otherwise" strategy.
215+
Mocking, by necessity, introduces coupling between your tests and the
216+
implementation of your code under test. Higher coupling forces can lead to more
217+
frequent false test failures. Filtering by arguments is sufficient to ensure
218+
correctness, and is a looser form of coupling than an assertion.
219+
220+
The final benefit that vitest-when brings to the table is **strict typing when
221+
using TypeScript**. The arguments to both `calledWith` and `thenReturn` (and
222+
other behaviors) are checked against the mock function's arguments and return
223+
type, which helps you update tests during refactors _before_ you start getting
224+
annoyed. As of vitest 0.32.0, `mockReturnValue` (and friends) are typechecked
225+
but `expect().toHaveBeenCalledWith` is not.
226+
227+
## Try it yourself
228+
229+
If you enjoy using Vitest and you use mock functions in your test suites, you
230+
should give [vitest-when][] a try! Incorporating this style of stubbing has
231+
dramatically improved the quality of my code and tests, and I think it could do
232+
the same for you.
233+
234+
```shell
235+
npm install --save-dev vitest-when
236+
```
237+
238+
[vitest]: https://vitest.dev
239+
[mock functions]: https://vitest.dev/api/mock.html
240+
[asymmetric matchers]: https://vitest.dev/api/expect.html#expect-anything
241+
[jest mock functions]: https://jestjs.io/docs/mock-function-api
242+
[testdouble.js]: https://github.com/testdouble/testdouble.js
243+
[jest-when]: https://github.com/timkindberg/jest-when
244+
[vitest-when]: https://github.com/mcous/vitest-when
245+
[test double]: https://en.wikipedia.org/wiki/Test_double

0 commit comments

Comments
 (0)