DEV Community

Cover image for JS tests: mocking best practices
Alex Pla Alonso
Alex Pla Alonso

Posted on • Edited on

JS tests: mocking best practices

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 to jest.fn but also tracks calls to object[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 and options 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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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'),
})
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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]);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
noriller profile image
Bruno Noriller

You don't need to use let and beforeEach, just add it to a const 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.

Collapse
 
alexpladev profile image
Alex Pla Alonso

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 an afterAll in that describe block with a restoreAllMocks 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! 🙌🏻

Collapse
 
noriller profile image
Bruno Noriller

I usually go with resetMocks so the mock is already there.

Then in a beforeEach (or in each test) I just go with mock.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)

Thread Thread
 
alexpladev profile image
Alex Pla Alonso • Edited

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 in spyOn, 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 as never, 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!

Collapse
 
jackmellis profile image
Jack

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)

Collapse
 
alexpladev profile image
Alex Pla Alonso

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.

Collapse
 
szabgab profile image
Gabor Szabo

Welcome to DEV and congratulations on your first post!