Skip to content

Commit c243f67

Browse files
jonathan-stonecpojer
authored andcommitted
Document class mocks (#5383)
* WIP on documenting ES6 class mocks * WIP on Es6ClassMocks.md * WIP on Es6ClassMocks.md * Completed first draft of Es6ClassMocks.md * Second draft of Es6ClassMocks.md * Another draft of Es6ClassMocks.md. Still need to cut some content out - it's huge. * WIP: Updating with new info on automatic mocks. * WIP on Es6ClassMocks.md * Final draft of Es6ClassMocks.md * Keep code examples to 80 columns * Updated changelog * Removed incorrect statement about manual mocks * Fixed linter errors
1 parent 4585c73 commit c243f67

File tree

4 files changed

+320
-0
lines changed

4 files changed

+320
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
([#5376](https://github.com/facebook/jest/pull/5376))
1616
* `[expect]` Make `rejects` and `resolves` synchronously validate its argument.
1717
([#5364](https://github.com/facebook/jest/pull/5364))
18+
* `[docs]` Add tutorial page for ES6 class mocks.
19+
([#5383]https://github.com/facebook/jest/pull/5383))
1820

1921
## jest 22.1.4
2022

docs/Es6ClassMocks.md

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
---
2+
id: es6-class-mocks
3+
title: ES6 Class Mocks
4+
---
5+
Jest can be used to mock ES6 classes that are imported into files you want to test.
6+
7+
ES6 classes are constructor functions with some syntactic sugar. Therefore, any mock for an ES6 class must be a function or an actual ES6 class (which is, again, another function). So you can mock them using [mock functions](MockFunctions.md).
8+
9+
## An ES6 Class Example
10+
We'll use a contrived example of a class that plays sound files, `SoundPlayer`, and a consumer class which uses that class, `SoundPlayerConsumer`. We'll mock `SoundPlayer` in our tests for `SoundPlayerConsumer`.
11+
12+
```javascript
13+
// sound-player.js
14+
export default class SoundPlayer {
15+
constructor() {
16+
this.foo = 'bar';
17+
}
18+
19+
playSoundFile(fileName) {
20+
console.log('Playing sound file ' + fileName);
21+
}
22+
}
23+
```
24+
25+
```javascript
26+
// sound-player-consumer.js
27+
import SoundPlayer from './sound-player';
28+
29+
export default class SoundPlayerConsumer {
30+
constructor() {
31+
this.soundPlayer = new SoundPlayer();
32+
}
33+
34+
playSomethingCool() {
35+
const coolSoundFileName = 'song.mp3';
36+
this.soundPlayer.playSoundFile(coolSoundFileName);
37+
}
38+
}
39+
```
40+
41+
## The 4 ways to create an ES6 class mock
42+
43+
### Automatic mock
44+
Calling `jest.mock('./sound-player')` returns a useful "automatic mock" you can use to spy on calls to the class constructor and all of its methods. It replaces the ES6 class with a mock constructor, and replaces all of its methods with [mock functions](MockFunctions.md) that always return `undefined`. Method calls are saved in `theAutomaticMock.mock.instances[index].methodName.mock.calls`.
45+
46+
If you don't need to replace the implementation of the class, this is the easiest option to set up. For example:
47+
48+
```javascript
49+
import SoundPlayer from './sound-player';
50+
import SoundPlayerConsumer from './sound-player-consumer';
51+
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
52+
53+
beforeEach(() => {
54+
// Clear all instances and calls to constructor and all methods:
55+
SoundPlayer.mockClear();
56+
});
57+
58+
it('We can check if the consumer called the class constructor', () => {
59+
const soundPlayerConsumer = new SoundPlayerConsumer();
60+
expect(SoundPlayer).toHaveBeenCalledTimes(1);
61+
});
62+
63+
64+
it('We can check if the consumer called a method on the class instance', () => {
65+
// Show that mockClear() is working:
66+
expect(SoundPlayer).not.toHaveBeenCalled();
67+
68+
const soundPlayerConsumer = new SoundPlayerConsumer();
69+
// Constructor should have been called again:
70+
expect(SoundPlayer).toHaveBeenCalledTimes(1);
71+
72+
const coolSoundFileName = 'song.mp3';
73+
soundPlayerConsumer.playSomethingCool();
74+
75+
// mock.instances is available with automatic mocks:
76+
const mockSoundPlayerInstance = SoundPlayer.mock.instances[0];
77+
const mockPlaySoundFile = mockSoundPlayerInstance.playSoundFile;
78+
expect(mockPlaySoundFile.mock.calls[0][0]).toEqual(coolSoundFileName);
79+
// Equivalent to above check:
80+
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
81+
expect(mockPlaySoundFile).toHaveBeenCalledTimes(1);
82+
});
83+
```
84+
85+
### Manual mock
86+
Create a [manual mock](ManualMocks.md) by saving a mock implementation in the `__mocks__` folder. This allows you to specify the implementation, and it can be used across test files.
87+
88+
```javascript
89+
// __mocks__/sound-player.js
90+
91+
// Import this named export into your test file:
92+
export const mockPlaySoundFile = jest.fn();
93+
const mock = jest.fn().mockImplementation(() => {
94+
return { playSoundFile: mockPlaySoundFile };
95+
});
96+
97+
export default mock;
98+
```
99+
100+
Import the mock and the mock method shared by all instances:
101+
```javascript
102+
// sound-player-consumer.test.js
103+
import SoundPlayer, { mockPlaySoundFile } from './sound-player';
104+
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
105+
106+
beforeEach(() => {
107+
// Clear all instances and calls to constructor and all methods:
108+
SoundPlayer.mockClear();
109+
mockPlaySoundFile.mockClear();
110+
});
111+
112+
it('We can check if the consumer called the class constructor', () => {
113+
const soundPlayerConsumer = new SoundPlayerConsumer();
114+
expect(SoundPlayer).toHaveBeenCalledTimes(1);
115+
});
116+
117+
118+
it('We can check if the consumer called a method on the class instance', () => {
119+
const soundPlayerConsumer = new SoundPlayerConsumer();
120+
const coolSoundFileName = 'song.mp3';
121+
soundPlayerConsumer.playSomethingCool();
122+
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
123+
});
124+
```
125+
126+
### Calling [`jest.mock()`](JestObjectAPI.md#jestmockmodulename-factory-options) with the module factory parameter
127+
`jest.mock(path, moduleFactory)` takes a **module factory** argument. A module factory is a function that returns the mock.
128+
129+
In order to mock a constructor function, the module factory must return a constructor function. In other words, the module factory must be a function that returns a function - a higher-order function (HOF).
130+
131+
```javascript
132+
import SoundPlayer from './sound-player';
133+
const mockPlaySoundFile = jest.fn();
134+
jest.mock('./sound-player', () => {
135+
return jest.fn().mockImplementation(() => {
136+
return { playSoundFile: mockPlaySoundFile };
137+
});
138+
});
139+
```
140+
141+
A limitation with the factory parameter is that, since calls to `jest.mock()` are hoisted to the top of the file, it's not possible to first define a variable and then use it in the factory. An exception is made for variables that start with the word 'mock'. It's up to you to guarantee that they will be initialized on time!
142+
143+
### Replacing the mock using [`mockImplementation()`](MockFunctionAPI.md#mockfnmockimplementationfn) or [`mockImplementationOnce()`](MockFunctionAPI.md#mockfnmockimplementationoncefn)
144+
You can replace all of the above mocks in order to change the implementation, for a single test or all tests, by calling `mockImplementation()` on the existing mock.
145+
146+
Calls to jest.mock are hoisted to the top of the code. You can specify a mock later, e.g. in `beforeAll()`, by calling `mockImplementation()` (or `mockImplementationOnce()`) on the existing mock instead of using the factory parameter. This also allows you to change the mock between tests, if needed:
147+
148+
```javascript
149+
import SoundPlayer from './sound-player';
150+
jest.mock('./sound-player');
151+
152+
describe('When SoundPlayer throws an error', () => {
153+
beforeAll(() => {
154+
SoundPlayer.mockImplementation(() => {
155+
return { playSoundFile: () => { throw new Error('Test error')} };
156+
});
157+
});
158+
159+
it('Should throw an error when calling playSomethingCool', () => {
160+
const soundPlayerConsumer = new SoundPlayerConsumer();
161+
expect(() => soundPlayerConsumer.playSomethingCool()).toThrow();
162+
});
163+
});
164+
```
165+
166+
## In depth: Understanding mock constructor functions
167+
Building your constructor function mock using `jest.fn().mockImplementation()` makes mocks appear more complicated than they really are. This section shows how you can create your own simple mocks to illustrate how mocking works.
168+
169+
### Manual mock that is another ES6 class
170+
If you define an ES6 class using the same filename as the mocked class in the `__mocks__` folder, it will serve as the mock. This class will be used in place of the real class. This allows you to inject a test implementation for the class, but does not provide a way to spy on calls.
171+
172+
For the contrived example, the mock might look like this:
173+
174+
```javascript
175+
// __mocks/sound-player.js
176+
export default class SoundPlayer {
177+
constructor() {
178+
console.log('Mock SoundPlayer: constructor was called');
179+
}
180+
181+
playSoundFile() {
182+
console.log('Mock SoundPlayer: playSoundFile was called');
183+
}
184+
}
185+
```
186+
187+
### Simple mock using module factory parameter
188+
The module factory function passed to `jest.mock(path, moduleFactory)` can be a HOF that returns a function*. This will allow calling `new` on the mock. Again, this allows you to inject different behavior for testing, but does not provide a way to spy on calls.
189+
190+
#### * Module factory function must return a function
191+
In order to mock a constructor function, the module factory must return a constructor function. In other words, the module factory must be a function that returns a function - a higher-order function (HOF).
192+
193+
```javascript
194+
jest.mock('./sound-player', () => {
195+
return function() {
196+
return { playSoundFile: () => {} };
197+
};
198+
});
199+
```
200+
201+
***Note: Arrow functions won't work***
202+
203+
Note that the mock can't be an arrow function because calling `new` on an arrow function is not allowed in Javascript. So this won't work:
204+
205+
```javascript
206+
jest.mock('./sound-player', () => {
207+
return () => { // Does not work; arrow functions can't be called with new
208+
return { playSoundFile: () => {} };
209+
};
210+
});
211+
```
212+
213+
This will throw ***TypeError: _soundPlayer2.default is not a constructor***, unless the code is transpiled to ES5, e.g. by babel-preset-env. (ES5 doesn't have arrow functions nor classes, so both will be transpiled to plain functions.)
214+
215+
## Keeping track of usage (spying on the mock)
216+
Injecting a test implementation is helpful, but you will probably also want to test whether the class constructor and methods are called with the correct parameters.
217+
218+
### Spying on the constructor
219+
220+
In order to track calls to the constructor, replace the function returned by the HOF with a Jest mock function. Create it with [`jest.fn()`](JestObjectAPI.md#jestfnimplementation), and then specify its implementation with `mockImplementation()`.
221+
222+
```javascript
223+
import SoundPlayer from './sound-player';
224+
jest.mock('./sound-player', () => {
225+
// Works and lets you check for constructor calls:
226+
return jest.fn().mockImplementation(() => {
227+
return { playSoundFile: () => {} };
228+
});
229+
});
230+
```
231+
232+
This will let us inspect usage of our mocked class, using `SoundPlayer.mock.calls`:
233+
`expect(SoundPlayer).toHaveBeenCalled();`
234+
or near-equivalent:
235+
`expect(SoundPlayer.mock.calls.length).toEqual(1);`
236+
237+
### Spying on methods of our class
238+
Our mocked class will need to provide any member functions (`playSoundFile` in the example) that will be called during our tests, or else we'll get an error for calling a function that doesn't exist. But we'll probably want to also spy on calls to those methods, to ensure that they were called with the expected parameters.
239+
240+
A new object will be created each time the mock constructor function is called during tests. To spy on method calls in all of these objects, we populate `playSoundFile` with another mock function, and store a reference to that same mock function in our test file, so it's available during tests.
241+
242+
```javascript
243+
import SoundPlayer from './sound-player';
244+
const mockPlaySoundFile = jest.fn();
245+
jest.mock('./sound-player', () => {
246+
return jest.fn().mockImplementation(() => {
247+
return { playSoundFile: mockPlaySoundFile };
248+
// Now we can track calls to playSoundFile
249+
});
250+
});
251+
```
252+
253+
The manual mock equivalent of this would be:
254+
```javascript
255+
// __mocks__/sound-player.js
256+
257+
// Import this named export into your test file
258+
export const mockPlaySoundFile = jest.fn();
259+
const mock = jest.fn().mockImplementation(() => {
260+
return { playSoundFile: mockPlaySoundFile }
261+
});
262+
263+
export default mock;
264+
```
265+
266+
Usage is similar to the module factory function, except that you can omit the second argument from `jest.mock()`, and you must import the mocked method into your test file, since it is no longer defined there. Use the original module path for this; don't include `__mocks__`.
267+
268+
### Cleaning up between tests
269+
To clear the record of calls to the mock constructor function and its methods, we call [`mockClear()`](MockFunctionAPI.md#mockfnmockclear) in the `beforeEach()` function:
270+
271+
```javascript
272+
beforeEach(() => {
273+
SoundPlayer.mockClear();
274+
mockPlaySoundFile.mockClear();
275+
});
276+
```
277+
278+
## Complete example
279+
Here's a complete test file which uses the module factory parameter to `jest.mock`:
280+
281+
```javascript
282+
// sound-player-consumer.test.js
283+
import SoundPlayerConsumer from './sound-player-consumer';
284+
import SoundPlayer from './sound-player';
285+
286+
const mockPlaySoundFile = jest.fn();
287+
jest.mock('./sound-player', () => {
288+
return jest.fn().mockImplementation(() => {
289+
return { playSoundFile: mockPlaySoundFile };
290+
});
291+
});
292+
293+
beforeEach(() => {
294+
SoundPlayer.mockClear();
295+
mockPlaySoundFile.mockClear();
296+
});
297+
298+
it('The consumer should be able to call new() on SoundPlayer', () => {
299+
const soundPlayerConsumer = new SoundPlayerConsumer();
300+
// Ensure constructor created the object:
301+
expect(soundPlayerConsumer).toBeTruthy();
302+
});
303+
304+
it('We can check if the consumer called the class constructor', () => {
305+
const soundPlayerConsumer = new SoundPlayerConsumer();
306+
expect(SoundPlayer).toHaveBeenCalledTimes(1);
307+
});
308+
309+
it('We can check if the consumer called a method on the class instance', () => {
310+
const soundPlayerConsumer = new SoundPlayerConsumer();
311+
const coolSoundFileName = 'song.mp3';
312+
soundPlayerConsumer.playSomethingCool();
313+
expect(mockPlaySoundFile.mock.calls[0][0]).toEqual(coolSoundFileName);
314+
});
315+
316+
```

website/i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"tagline": "🃏 Delightful JavaScript Testing",
77
"cli": "Jest CLI Options",
88
"configuration": "Configuring Jest",
9+
"es6-class-mocks": "ES6 Class Mocks",
910
"expect": "Expect",
1011
"getting-started": "Getting Started",
1112
"api": "Globals",

website/sidebars.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"tutorial-async",
1515
"timer-mocks",
1616
"manual-mocks",
17+
"es6-class-mocks",
1718
"webpack",
1819
"puppeteer",
1920
"migration-guide",

0 commit comments

Comments
 (0)