TL;DR
You can mock imports in multiple ways, all of them valid, but it's usually best to use jest.mock()
for "static" mocks and jest.spyOn()
for mocks you need to change the implementation for specific test cases.
There's also some global configuration and test file structure which can help us with that and to make sure our tests are independent and consistent.
The problem
Tests are usually the forgotten ones when it comes to best practices. They're getting a similar treatment as CSS architecture or HTML accessibility, forgotten until you need to care about them.
It's also a fact that JS testing (and specially in the Frontend) is quite recent yet, and I'm sure this will improve with time, but the reality is that most companies still don't add tests to their JS codebases, and if they do, they're usually a mess.
In JS you can usually achieve the same result in multiple ways, but that doesn't mean all of them are equal. That's why I believe best practices are even more important in our ecosystem.
What about mocks?
Mocks are probably the hardest part to keep organised and clean in your test suite. But there are some decisions you can make to help you with that.
The first thing you need to know is what kind of test you are writing. I usually refer to this article by Kent C. Dodds, one of (if not the most) relevant actors in the Javascript testing community. Depending on the kind of test, the data you're mocking and how you're doing it should change.
Some considerations
In this article I'm going to give some examples written with Jest, since it's still the most popular JS test runner out there, but note that if you're using Vitest (and you probably should) almost all the syntax is the same, so this applies too.
Global configuration
Neither Jest nor Vitest clear, reset or restore your mocks by default after each test. This is very opinionated, but imho that shouldn't be the default.
You want your tests to be consistent and independent from each other, so a test should not rely on a previous test to pass or fail.
Let's see what means to clear
, reset
and restore
a mock, based on Jest's docs:
clear | reset | restore | |
---|---|---|---|
Clears mock calls, instances, contexts and results | ✅ | ✅ | ✅ |
Removes mocked return value or implementation | ❌ | ✅ | ✅ |
Restores the original implementation | ❌ | ❌ | ✅ |
So not activating clearMocks
in your global config means a mocked function will keep all of its calls from previous tests, so you could easily get to a false positive/negative when asserting a mocked function has been called or not, and a test could even pass when we run the whole test suite but fail if we run it alone. We don't want that, so clearMocks
should be set to true
in the global config.
In regards to resetMocks
and restoreMocks
, it's not so straight forward. If we really want a clean state before each test, we should enable restoreMocks
, since that is the only one that restores the original implementation. The caveat is that if we mock something for one test, we usually need that to be mocked for all tests in that test suite, maybe just with different mock implementations/return values.
Even there are some exceptions, I recommend setting restoreMocks
to true
, because that's the only way you'll be sure each test is independent and that will allow you to achieve a more sustainable test suite. But that's a strong choice, so you'll need to adapt the way you organise and set your mocks to avoid code duplicity hell in each test.
Luckily, there's a way to have the best of both worlds. Jest has beforeEach
, afterEach
, beforeAll
and afterAll
methods that, in conjunction with a good test structure, it can make sure you have the desired mocks for every test. But we'll come back to this later.
jest.fn()
vs jest.spyOn()
vs jest.mock()
As I said, there are lots of ways you can mock things in JS with Jest or Vitest, but even though you can achieve a similar result with most of them it doesn't mean you should use them indistinctly.
Again, as per Jest docs:
-
jest.fn(implementation?)
: returns a new, unused mock function. Optionally takes a mock implementation. -
jest.spyOn(object, methodName)
: creates a mock function similar tojest.fn
but also tracks calls toobject[methodName]
. Returns a Jest mock function. -
jest.mock(moduleName, factory, options)
: mocks a module with an auto-mocked version when it is being required.factory
andoptions
are optional.
When to use each one?
I won't argue what should or shouldn't be mocked in this article, that's a whole different topic. Usually we'll be using this utilities to replace the behaviour of a function because we don't want it to affect the test outcome. That's useful when testing code that relies on other utilities, third party libraries or Backend endpoints.
jest.fn
This is the one we'll use most frequently. We'll usually use it to mock function parameters (or function props if we're testing components).
const mockedFn = jest.fn(() => 'mocked return value');
const result = methodToTest(mockedFn)
jest.spyOn
We should use this one when we want to mock an imported method and we want different mocked implementations/return values depending on the test. For example, you want to test both the success and error flow after an endpoint call response, so your mock should resolve and throw respectively.
const mockedFn = jest
.spyOn(methodModule, 'methodName')
.mockReturnValue('mocked return value');
const result = methodToTest()
jest.mock
When you want to mock a full imported module or some parts and you want it to have the same behaviour across all tests in that file, you'll use jest.mock
. It's recommended to include jest.requireActual
in the mock since that'll be leaving the rest of the module that you're not explicitly mocking with its original implementation.
jest.mock('methodModule', () => ({
...jest.requireActual('methodModule'),
methodName: jest.fn(() => 'mocked return value'),
})
But why?
You could definitely use jest.spyOn
and jest.mock
both for consistent and changing implementations respectively, but I believe that this way makes more sense, and you'll get it when we get to the last section of this article.
The main goal is to achieve a well organised test structure, and we need to make some decisions to get there.
If you want to change the implementation/return value of an imported function mocked with jest.mock
, you should previously declare a variable, assign it a jest.fn
with the "default" implementation and then pass it to jest.mock
. After that, you'll need to refer to that variable in the specific test you want to change its implementation, so it makes it a bit more verbose and makes your top-level mocks readability a bit more complex.
Besides, jest.spyOn
allows you to only mock a specific element of the exported module without having to worry about overwriting the rest of the module exported elements.
Test structure
You can think this is not relevant, I though it too not so long ago, but this can help you wrap everything mentioned before and make your tests more readable, sustainable and consistent. It's like the cherry on top where everything makes sense.
If you've been writing tests you'll already know we have describe
and it
blocks, the first one is used to group tests and the second one to define a specific test case.
We'll try to use the describe
blocks to structure our tests taking into consideration the different scenarios where we need to test our code. That means we'll be using them to set mock implementations that will be shared across all test cases inside that block, and we can use the previously mentioned beforeEach
method to achieve that.
Show me the code
Let me write an example. Imagine we have a the following function:
import { irrelevantMethod } from 'module-1';
import { getSomeThings } from 'module-2';
export function getArrayOfThings(amount) {
if (!amount) {
return [];
}
irrelevantMethod();
const result = getSomeThings();
if (!result) return [];
return result.slice(0, Math.min(amount, 4));
}
Our function has some dependencies, which are:
-
irrelevantMethod
: this is a method our function has to call but that doesn't affect the outcome in any way (hence its name). A real-life example of this could be event tracking. -
getSomeThings
: this method does affect the result of our function, so we'll be mocking it and changing its mocked return value in some of our tests. We're assuming we know this method can only return null or a valid array of a fixed considerable length.
If we put everything we've seen together, the test file for this method could look something like this:
import * as module2 from 'module-2';
import { getArrayOfThings } from '../utils.js';
const mockedIrrelevantMethod = jest.fn();
jest.mock(() => ({
...jest.requireActual('module-1'),
irrelevantMethod: mockedIrrelevantMethod,
}));
describe('getArrayOfThings', () => {
it('should return an empty array if amount is 0', () => {
const result = getArrayOfThings(0);
expect(result).toEqual([]);
});
it('should call irrelevantMethod and getSomeThings if amount is greater than 0', () => {
const mockedGetSomeThings = jest.spyOn(module2, 'getSomeThings');
getArrayOfThings(1);
expect(mockedIrrelevantMethod).toBeCalled();
expect(mockedGetSomeThings).toBeCalled();
});
describe('getSomeThings returns null', () => {
let mockedGetSomeThings;
beforeEach(() => {
mockedGetSomeThings = jest.spyOn(module2, 'getSomeThings').mockReturnValue(null);
});
it('should return an empty array', () => {
const result = getArrayOfThings(1);
expect(result).toEqual([]);
});
});
describe('getSomeThings returns an array', () => {
let mockedGetSomeThings;
beforeEach(() => {
mockedGetSomeThings = jest.spyOn(module2, 'getSomeThings').mockReturnValue([1, 2, 3, 4, 5, 6]);
});
it('should return an array of "amount" elements if "amount" is 4 or less', () => {
const result = getArrayOfThings(3);
expect(result).toEqual([1, 2, 3]);
});
it('should return an array of 4 elements if "amount" is greater than 4', () => {
const result = getArrayOfThings(5);
expect(result).toEqual([1, 2, 3, 4]);
});
});
});
This is a very simple example, sometimes you'll need to have multiple describe
levels, each one with its beforeEach
callback that will affect all the tests in that block. That's fine, and will actually help you keep your tests even more readable.
Conclusion
This is a very opinionated way of organising tests and mocks, but after many years testing javascript code, it's by far the approach that works best for me.
Top comments (7)
You don't need to use
let
andbeforeEach
, just add it to aconst
and possibly use the jest config to clear mocks between tests.Also, a cool thing to do when using spy is:
jest.spyOn(module, module.myFunction.name)
this way you can safely refactor and change names without needing to remember to change the name strings of the spy.You actually need the beforeEach if you enable
restoreMocks
on the global config as I recommend.Another way of achieving the same outcome without
restoreMocks
on the global config would be to do what you say but then add anafterAll
in that describe block with arestoreAllMocks
to leave that "clean slate" we were aiming for.On the other hand, I didn't know about that
module.myFunction.name
feature, thank you so much for the tip, I'll add it to the article! 🙌🏻I usually go with
resetMocks
so the mock is already there.Then in a
beforeEach
(or in each test) I just go withmock.mockImplementation()
. (almost sure that if you declare the mock with an implementation it persists, and only clear if you override that implementation)Unless you make it global, the mock scope is usually the file (module) or the describe/test block, so I really don't need to mind that leaking to other tests.
When I do need a global mock, this would probably destroy them and make life a little harder. (Global mocks are usually for some arcane API that jsdom doesn't support and that usually doesn't make any difference in the tests, but that some framework/component library needs so you have to mock it just to make things work)
Thanks for your POV, as I said there's lots of ways of doing this, this is just what has worked the best for me and my colleagues.
Just a nitpick, the mock scope is the file, not the describe/test block. If you mock the implementation of a method and don't restore/reset it, it persists even if you exit the describe block and enter the following one if you're still in the same file.
And regarding what you suggested about passing
module.myFunction.name
as the second parameter inspyOn
, just wanted to mention that I've tried that and Typescript is not a big fan of that and forces you to force cast that asnever
, at least I couldn't find a "clean" way of preventing that ts error.Anyway, in JS that works and is clearly better than using the hardcoded string, so thanks anyway!
Nice in depth article. I've always held that mocking imports is a messy solution and following a dependency inversion pattern is a much more robust alternative (but requires more discipline)
I completely agree, sadly SOLID principles are something that we still don't see so often in JS (and specially in the FE), so when I can't strictly follow the DIP principle and need to test something with imports, this is how I mock them.
Welcome to DEV and congratulations on your first post!