Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
December 29, 2022 08:02 pm GMT

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 configuration docs:

  • clearMocks: Automatically clear mock calls, instances, contexts and results before every test. Equivalent to calling jest.clearAllMocks() before each test. This does not remove any mock implementation that may have been provided.
  • resetMocks: Automatically reset mock state before every test. Equivalent to calling jest.resetAllMocks() before each test. This will lead to any mocks having their fake implementations removed but does not restore their initial implementation.
  • restoreMocks: Automatically restore mock state and implementation before every test. Equivalent to calling jest.restoreAllMocks() before each test. This will lead to any mocks having their fake implementations removed and restores their initial 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).

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.

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.

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.


Original Link: https://dev.to/alexpladev/js-tests-mocking-best-practices-10kp

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To