Striving with Jest a.k.a. Jan's pocket guide to Unit Testing with Jest
How to unit test with stubs, spies, mocks and matchers without the hangover using Jest.
1 Motivational Speech
Unit testing is the entry level to automated testing. Tests can be written as simple low-level tests, that run fast and are of low maintenance. However, writing good unit tests can be quite difficult. The biggest challenge is to build a small and maintainable sandbox in which a small piece of code can be executed and observed.
Unit tests need to run fast, hundreds of times during development. Otherwise, they are not useful as feedback loop to drive development, and you will start to do something else.
The code you want to test can be complex and require a lot of collaborating objects. Test doubles can help to shorten the feedback cycle, through lighter weight creation of collaborating objects. We can create test doubles for collaborators not needed in testing one single aspect, and inject them into the test subject. Or we can stub out collaborators not needed. Test doubles can provide easier to access state/behavior, add additional access methods and capture method invocations with arguments.
1.1 Mocks suck
Mocks are black magic and we don't like them!
The most common form of test doubles used are mocks. Mocks tend to break the standard test structure (arrange, act, assert) of unit tests. Mocks add hidden expectations, thus we may test behavior that doesn't matter. Especially when behavior verification is involved. Behavior verification seems to yield program behavior that is not the expected behavior to which method calls lead, but looks very much alike.
This comes with all the problems you know already to well. Specified behavior in mocks leads to implementation coupling, but you need to specify it to make the mock a valid object. Refactoring is inhibited, because you need to keep the call chains intact to keep the same outcome. Mock tests will break! Behavior verification testing inhibits behavior preserving changes, making your tests even more brittle.
Which eventually leads to lower confidence in tests, because you aren't testing genuine behavior.
1.2 In an ideal world
In an ideal world we would create a unit test. Define a dummy implementation for every collaborating object. We use simple objects just needed, e.g. for compilation, but never used in collaboration with object under test. No input has to be provided, no output needs to be checked. A good example for that pattern was coined by Kent Beck as a "Crash Test Dummy": a class that throws exceptions when it's methods are called.
Example with manual dummy
import { open } from "./test-object";
describe("open", () => {
it("should return OK with no origins and existing context", () => {
const dummy = new (class extends ContextProvider {
getContext() {
return new UriReference("Fake Context", "", new URL("http://fake.url/"));
}
})(null);
const actual: Status = open([], dummy);
expect(actual.getCode()).toEqual(Status.OK);
});
});
2 Mocking with Jest
Jest is a testing framework that allows you to mock existing modules and their functions:
Three types of module and function mocking in Jest:
jest.fn(): Stub a functionjest.spyOn(): Spy or mock a functionjest.mock(): Mock a module
YOU SHOULD MASTER THESE THREE FUNCTIONS.
3 Stubbing functions
A function stub is a substitute for a function with a mocked implementation.
3.1 Stub a function with jest.fn()
Stub a function with an empty implementation that can be observed.
jest.fn() returns a mock function to:
- Capture calls
- Set return values
- Change the implementation
it("should return undefined by default", () => {
const target = jest.fn();
const actual = target("foo");
expect(actual).toBeUndefined();
expect(target).toHaveBeenCalledTimes(1);
expect(target).toHaveBeenCalledWith("foo");
});
3.2 Behavior of a Stub
Stubs change implementation, return canned values, AND can be observed.
test("stub with custom implementation", () => {
const target = jest.fn((key) => `${key}: bar`);
expect(target("foo")).toBe("foo: bar");
expect(target).toHaveBeenCalledWith("foo");
});
test("stub with one time custom implementation", () => {
const target = jest.fn().mockImplementationOnce((key) => `${key}: bar`);
expect(target("foo")).toBe("foo: bar");
expect(target).toHaveBeenCalledWith("foo");
// any other call
expect(target("whatever")).toBeUndefined();
expect(target).toHaveBeenCalledWith("whatever");
});
test("stub with custom return value", () => {
const target = jest.fn().mockReturnValue("bar");
expect(target("foo")).toBe("bar");
expect(target).toHaveBeenCalledWith("foo");
});
test("stub with promise resolution", () => {
const target = jest.fn().mockResolvedValue("bar");
expect(target("foo")).resolves.toBe("bar");
expect(target).toHaveBeenCalledWith("foo");
});
Add clearMocks: true to your jest.config.ts to reset the Mocks between test runs.
You can also add beforeEach(() => { jest.clearAllMocks(); }); to your test suite.
3.3 Dependency Injection
Sometimes your code depends on other objects. You can stub away unrelated objects to your testing. Run your test subject, then assert how the mock was called and with what arguments:
const doAdd = (a, b, callback) => {
callback(a + b);
};
it("should call callback w/ arguments added", () => {
const target = jest.fn();
doAdd(1, 2, target);
expect(target).toHaveBeenCalledWith(3);
});
4 Mocking modules
Jest has its own module registry which is a cache for all required/imported modules.
4.1 Mock a module manually
You can use jest.mock() to set all exports of a module to the Mock object.
import { doAdd, doSubtract } from "./app";
import * as math from "./math";
// Set all module functions to jest.fn()
jest.mock("./math.js");
it("should call math.add", () => {
doAdd(1, 2);
expect(math.add).toHaveBeenCalledWith(1, 2);
});
it("should call math.subtract", () => {
doSubtract(1, 2);
expect(math.subtract).toHaveBeenCalledWith(1, 2);
});
Add resetModules: true to your jest.config.ts to reset the mocked modules between test runs.
You can also add beforeEach(() => { jest.resetModules(); }); to your test suite.
4.2 Partial mocking
You can mock a subset of a module and keep some of its original implementation:
import defaultExport, { foo, bar } from '../zip-zap';
jest.mock('../zip-zap', () => {
const originalModule = jest.requireActual('../zip-zap');
//Mock the default export and named export 'foo'
return {
__esModule: true,
...originalModule,
default: jest.fn().mockReturnValue('mocked default'),
foo: jest.fn().mockReturnValue('mocked foo'),
};
});
test('module with partial mock', () => {
expect(defaultExport()).toBe('mocked default');
expect(foo()).toBe('mocked foo');
expect(bar()).toBe('original bar');
});
4.3 Auto mocking of Node modules
Custom modules are automatically mocked when you have a manual mock in place (e.g.: mocks/lodash.js). Core Node.js modules, like fs, are not mocked by default.
They can be mocked explicitly, like jest.mock('fs'). Place a manual mock in the __mocks__ directory:
.
├── config
├── __mocks__
│ └── fs.js
├── models
│ ├── __mocks__
│ │ └── user.js
│ └── user.js
├── node_modules
└── views
See Jest manual mocks documentation for more details.
NOTE: Dangerous but possible: Add automock: true to your jest.config.ts to automatically mock all node modules.
5 Spying on behavior
5.1 Spy (or mock) a function with jest.spyOn()
You can use jest.spyOn() to spy on a function. It's a combination of a stub and a spy. You can access the
original implementation with spyOn. Use spyOn if you want to observe a method to be called, but keep the original
implementation, or if you want to mock the implementation, but restore the original later in the suite.
Here we simply observe calls to math functions, but leave the original implementation in place:
import { doAdd } from "./app";
import * as math from "./math";
test("calls math.add", () => {
const target = jest.spyOn(math, "add");
// calls the original implementation
expect(doAdd(1, 2)).toEqual(3);
// and the spy stores the calls to add
expect(target).toHaveBeenCalledWith(1, 2);
});
Add restoreMocks: true to your jest.config.ts to restore the original behavior between test runs.
Or use beforeAll(() => { jest.restoreAllMocks(); }) to restore mocks after a test suite.
5.2 Using matchers
Make less demanding asserts on how mock functions have been called with custom matchers:
// The mock function was called at least once
expect(mockFn).toHaveBeenCalled();
// The mock function was called exactly three times
expect(mockFn).toHaveBeenCalledTimes(3)
// The mock function was called at least once with the specified args
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
// The last call to the mock function was called with the specified args
expect(mockFn).toHaveBeenLastCalledWith(arg1, arg2);
// The nth call to the mock function was called with the specified args
expect(mockFn).toHaveBeenNthCalledWith(nthCall, arg1, arg2, ...)
expect(mockFn).toHaveReturned()
expect(mockFn).toHaveReturnedTimes(number)
expect(mockFn).toHaveReturnedWith(value)
expect(mockFn).toHaveLastReturnedWith(value)
expect(mockFn).toHaveNthReturnedWith(nthCall, value)
// All calls and the name of the mock is written as a snapshot
expect(mockFn).toMatchSnapshot(propertyMatchers?, hint?);
expect(mockFn).toMatchInlineSnapshot(propertyMatchers?, inlineSnapshot);
expect(mockFn).toMatch(regexp | string);
expect(mockFn).toMatchObject(object);
5.3 Custom Matchers
Jest supports some syntactic sugar for common forms of inspecting the .mock property:
// The mock function was called at least once
expect(mockFn.mock.calls.length).toBeGreaterThan(0);
// The mock function was called at least once with the specified args
expect(mockFn.mock.calls).toContainEqual([arg1, arg2]);
// The last call to the mock function was called with the specified args
expect(mockFn.mock.calls[mockFn.mock.calls.length - 1]).toEqual([
arg1,
arg2,
]);
// The first arg of the last call to the mock function was `42`
// (note that there is no sugar helper for this specific of an assertion)
expect(mockFn.mock.calls[mockFn.mock.calls.length - 1][0]).toBe(42);
// A snapshot will check that a mock was invoked the same number of times,
// in the same order, with the same arguments. It will also assert on the name.
expect(mockFn.mock.calls).toEqual([[arg1, arg2]]);
expect(mockFn.getMockName()).toBe('a mock name');
5.4 Accessing the mock
All mock functions have a .mock property. It contains data about how the function has been called.
What the function returned is kept. The mock object tracks the value of this for each call, so it
is possible to inspect this as well:
// The function was called exactly once
expect(mockFn.mock.calls).toHaveLength(1);
// The first arg of the first call to the function was 'first arg'
expect(mockFn.mock.calls[0][0]).toBe('first arg');
// The second arg of the first call to the function was 'second arg'
expect(mockFn.mock.calls[0][1]).toBe('second arg');
// The return value of the first call to the function was 'return value'
expect(mockFn.mock.results[0].value).toBe('return value');
// The function was called with a certain `this` context: the `element` object.
expect(mockFn.mock.contexts[0]).toBe(element);
// This function was instantiated exactly twice
expect(mockFn.mock.instances.length).toBe(2);
// The object returned by the first instantiation of this function
// had a `name` property whose value was set to 'test'
expect(mockFn.mock.instances[0].name).toBe('test');
// The first argument of the last call to the function was 'test'
expect(mockFn.mock.lastCall[0]).toBe('test');
5.5 Under specify your matchers
/* target is called with a non-null argument */
expect(mockFn).toHaveBeenCalledWith(expect.anything());
/* target is called with an argument of type number */
expect(mockFn).toHaveBeenCalledWith(expect.any(Number));
/* matches even if received contains additional elements */
expect(['zip', 'zap', 'zup']).toEqual(expect.arrayContaining(expected));
/* does not match if received does not contain expected elements */
expect(['zip', 'zap']).not.toEqual(expect.arrayContaining(expected));
/* matches if the actual array does not contain the expected elements */
expect(['zip', 'zap', 'zup']).toEqual(expect.not.arrayContaining(expected));
/* compare float in object properties */
expect.closeTo(number, numDigits?)
/* matches any received object that recursively matches the expected properties */
expect.objectContaining(object)
/* matches any received object that does not recursively match the expected properties */
expect.not.objectContaining(object)
/* matches the received value if it is a string that contains the exact expected string */
expect.stringContaining(string)
/* matches the received value if it is not a string or if it is a string that does not contain the exact expected string */
expect.not.stringContaining(string)
/* matches the received value if it is a string that matches the expected string or regular expression */
expect.stringMatching(string | regexp)
expect.not.stringMatching(string | regexp)
const expected = [
expect.stringMatching(/^Alic/),
expect.stringMatching(/^[BR]ob/),
];
expect(['Alicia', 'Roberto', 'Evelina']).toEqual(
expect.arrayContaining(expected),
);
6 Clear, Reset & Restore
They are different functions to clear, reset and restore created mocks. The behaviors of each function and their corresponding config directive are different. In the case of config directives, the explained behavior takes place in between each test making them more and more isolated from the other tests.
References to mockFn are implying a sample Jest mock function under each of these actions.
6.1 Clear Mocks
The function jest.clearAllMocks() and config directive clearMocks:[boolean] reset all the mocks usage data,
NOT their implementation. Note that this replaces mockFn.mock not just mockFn.mock.calls and mockFn.mock.instances,
so avoid assigning that object to other variables to prevent stale data access.
Clearing mocks before/between test blocks is a good practice, as it ensures that top-level defined mock functions are not
carrying over any state from previous tests and assertions like toHaveBeenCalledTimes() or toHaveBeenCalledWith() are
representative for the current test.
6.2 Reset Mocks
The function jest.resetAllMocks() and config directive resetMocks:[boolean] goes one step further than "clear mocks" and
also takes care of resetting the implementation to a no return function. In other words, it will replace every mock
function with a new jest.fn().
This is useful when you want to ensure that mock implementations do not carry over from one test to another.
6.3 Restore Mocks
The function jest.restoreAllMocks() and config directive restoreMocks:[boolean] restores the original implementation of "spies".
So if you have used jest.spyOn(object, "function"), calling mockRestore() will restore the original function of that object, effectively nullifying your spy.
In cases where we manually overwrite functions with jest.fn() (not spies), we have to take care of implementation
restoration ourselves as Jest won't be doing it automatically.
While jest.restoreAllMocks() and its config directive ONLY apply to spies, calling mockRestore() on a stub will do the same as mockReset().
6.4 Resetting vs. Restoring Spies
Since every spy in Jest is technically a stub that mock-implements the original function, resetting or restoring spies might have similar although crucially different effects in your tests.
Take into account that resetting a spy will turn it into a no-op jest.fn() while restoring it will remove any mock functionality.
So if your test relies on your spied function returning a certain value, and you mocked this with mockReturnValue(),
resetting the spy will make it return undefined while restoring it will make it return the original value.
6.5 Reset Modules
The function jest.resetModules() and resetModules:[boolean] resets Jest's module registry which is a cache for all
required/imported modules. Jest will re-import any required module after a call to this. Imagine a clean slate without
having to deal with all the mocked out modules in other tests.
Be aware that Jest already does this between test suites (files), so in general you won't have module mocks leaking into other tests. However, if you want or need to reset the module registry within a test suite, you can use this option.
6.6 Reset Module Registry
The function jest.resetModuleRegistry is an alias for resetModules, see:
https://github.com/facebook/jest/blob/7f69176c/packages/jest-runtime/src/index.ts#L1147.
6.7 Config Recommendation
For new projects we recommend setting clearMocks: true, resetMocks: true, and restoreMocks: true in your jest.config.ts file
to enforce resilient mocking patterns where you mock as close to your test as possible.
In existing projects that do not use any of the config options, we recommend as a minimum to set clearMocks: true to ensure consistent usage data across tests.
You should try to apply resetMocks: true and restoreMocks: true as well, but using those options may require major refactorings of your mocks.
In any circumstance: if you use resetMocks: true it is heavily recommended to use restoreMocks: true as well,
to make sure spies are properly restored instead of just reset.
7 Mocking Classes
ES6 classes are constructor functions with some syntactic sugar.
7.1 Mock and spy on class constructors
jest.mock('./sound-player', () => {
// Arrow functions don't work here, they can't be called with new
return function() {
return { playSoundFile: () => {} };
};
});
jest.mock('./sound-player', () => {
// Mocking non-default class exports
return {
SoundPlayer: function() {
return { playSoundFile: () => {} };
},
};
});
jest.mock('./sound-player', () => {
// Spying on the constructor lets you check for constructor calls
return jest.fn().mockReturnValue({ playSoundFile: () => {} });
});
jest.mock('./sound-player', () => {
// Spying on methods lets you check for method calls on `playSoundFile`
return jest.fn().mockReturnValue({ playSoundFile: jest.fn() });
});
const methodStub = jest.fn();
SomeClass.mockImplementation(() => {
return {
method: methodStub,
};
});
7.2 Mock a class method
// your jest test file below
import SoundPlayer from './sound-player';
const staticMethodStub = jest
.spyOn(SoundPlayer, 'brand')
.mockReturnValue('some-mocked-brand');
const getterMethodStub = jest
.spyOn(SoundPlayer.prototype, 'foo', 'get')
.mockReturnValue('some-mocked-result');
8 Faking Time
The native timer functions are less ideal for testing (i.e., setTimeout(), setInterval(), clearTimeout(), clearInterval()). They depend on real time to elapse. Fake timers allow you to control the passage of time.
8.1 Fake Timers in Jest
jest.useFakeTimers(): "modern" vs. "legacy"- See
@sinonjs/fake-timersfor details - "legacy": works with Promises using the flushPromises() workaround, but not Date
- "modern": works with Date, but not where await flushPromises() never resolves
jest.useFakeTimers({ doNotFake: ["performance"] }): for JSDOM performance.mark()
- See
jest.runAllTimers(): run all pending timersjest.runOnlyPendingTimers(): run the macro-tasks that are currently pending onlyjest.advanceTimersByTime(msToRun): advance all pending timers selectivelyjest.clearAllTimers(): remove all pending timersjest.useRealTimers(): restore original implementations of date and timer functions
8.2 Enable Fake Timers
Call jest.useFakeTimers() to enable it. It replaces the original implementation of timer functions, e.g. setTimeout().
Restore timers with jest.useRealTimers().
const asyncFooBar(callback) {
console.log('Ready....go!');
setTimeout(() => {
console.log("Time's up -- stop!");
callback && callback();
}, 1000);
}
beforeAll(() => {
jest.useFakeTimers();
jest.spyOn(globalThis, 'setTimeout');
})
afterAll(() => {
jest.useRealTimers();
})
test('waits 1 second before calling the callback', () => {
/* no await */ asyncFooBar();
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);
});
8.3 Run all timers
import { asyncFooBar } from './foo-bar';
beforeAll(() => {
jest.useFakeTimers();
});
test('calls the callback after 1 second', () => {
const target = jest.fn();
/* no await */ asyncFooBar(target);
// At this point in time, target should not have been called yet
expect(target).not.toHaveBeenCalled();
// Fast-forward until all timers have been executed
jest.runAllTimers();
// Now target should have been called!
expect(target).toHaveBeenCalled();
expect(target).toHaveBeenCalledTimes(1);
});
Recursive timer in use? (a timer sets a new timer in its own callback) use jest.runOnlyPendingTimers()
8.4 Using Fake Timers
describe("MyComponent", () => {
beforeAll(() => { jest.useFakeTimers("legacy"); });
afterAll(() => { jest.useRealTimers(); });
beforeEach(() => { jest.clearAllTimers(); });
it("should render shallow with defaults", async () => {
const actual = await fixture(<MyComponent root={"/"} />);
expect(actual).toMatchSnapshot();
});
});
9 Best practices
9.1 clearMocks, resetMocks, restoreMocks
// Clears all information stored in the mockFn.mock.calls, mockFn.mock.instances, mockFn.mock.contexts and mockFn.mock.results arrays.
mockFn.mockClear();
// Clears all information, and also replaces the mock implementation with an empty function, returning undefined.
mockFn.mockReset();
// Resets mock, and also restores the original (non-mocked) implementation.
mockFn.mockRestore();
// Only works when the mock was created with jest.spyOn(), Manually assigned jest.fn() have to be restored by yourself
// Clear all information on every mocked function.
jest.clearAllMocks();
// Resets the state of all mocks.
jest.resetAllMocks();
// Restores all mocks and replaced properties back to their original value.
jest.restoreAllMocks();
9.2 Mocking window, global, globalThis
const originalFetch = globalThis.fetch;
afterAll(() => {
// Not needed in JSDOM environment
globalThis.fetch = originalFetch;
});
test('should do a partial mock', () => {
globalThis.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({ zip: "foo", zap: "bar" }),
});
// run your test code
});
9.3 Mocking missing features in JSDOM
Some browser functions aren't provided in JSDOM, e.g. the case with window.matchMedia().
Mocking matchMedia should solve the issue:
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
Define the mock before the tested module is used, e.g, in jest.setup.ts with testSetupAfterEnv.
9.4 Mock the console
Disable console.warn completely:
beforeAll(() => {
jest.spyOn(console, "warn").mockImplementation(jest.fn());
});
Disable console.log selectively:
beforeAll(() => {
const log = console.log;
jest.spyOn(console, "log").mockImplementation((...args) => {
if (args[0] === "Unrelated log entry") {
return;
}
log(...args);
});
});
9.5 Test Mock Target
import { tricky } from "@foobar/complex-functions/tricky";
import { zipZap } from "./zip-zap";
jest.mock("@foobar/complex-functions/tricky`");
const target = jest.mocked(tricky);
beforeEach(() => {
target.mockClear(); // or `clearMocks: true` in jest.config.js
});
describe("zipZap", () => {
it(`returns valid number of results with defaults`, async () => {
target.mockResolvedValue({ zip: "foo", zap: "bar" });
const actual = await zipZap();
expect(actual).toHaveLength(1);
});
});
10 Ready. Set. Go
- Unit Testing In The Real World
- More guidance. More examples.
- When to write unit test?
- Measure test coverage.