|
| 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