NVNH.io Logo
NVNH.io

Sep 18, 2022

Avoid the pain of mocking modules with dependency injection

When you’re unit testing, there is no way around it: from time to time you’ll need mocking. Modern testing libraries have support for mocking modules. This means injecting mocks into the module system. Both Jest and Vitest have support for this.

When you look at the examples in the documentation, it seems simple enough. But when you try to do it in a production code base, it becomes a big frustrating mess.

These are quotes from a thread I came across. Luckily, it doesn’t have to be this way:

It would either work, or not work at all. It just felt completely random at times.

I have no clue what is happening behind the scenes when I run my tests.

It just feels so extremely magical.

Mocking modules is a TDD anti-pattern

Before we get into dependency Injection (DI), let’s see how mocking modules causes so much pain.

Since we hook into the module system, tests that share modules can no longer run in isolation, they will affect each other: if a module is mocked in one test, it will have to be mocked in other tests. And if you’re not careful, mocks from one test will creep into other tests. This is a TDD anti-pattern:

you should always start a unit test from a known and pre-configured state

A mock from one test creeping into another test is not “a known and pre-configured state”.

Modern testing libraries like Jest and Vitest do a good job of avoiding these problems because they:

  • Keep the module systems of test files separated
  • Always run tests from one file in the order that they are defined
  • Suggest clearing all mocks after each test with an afterEach()

But you still have to be careful what you’re doing:

  • Want to mock a module in one test, but not in the other? Too bad, you’ll have to put the tests in different files.
  • Forgot to clear mocks in a test? Pain.
  • Using it.concurrent() to run tests in parallel? Pain!

And on top of that: you need to know the library-specific API’s to inject your mocks into the module system.

Maybe this doesn’t seem too bad, but things can get quite subtle: you run your tests locally, everything seems fine, so you push the code. Your CI pipeline runs the tests, everything green, great! Life is good.
The next day you push some completely unrelated code and your test from the day before suddenly fails. Or worse: 3 weeks later someone else pushes completely unrelated code and some random test suddenly fails. WTF is going on?!

This is what we call “flaky” tests. They’re pretty much bugs in your tests, the worst kinds of bugs: those you can’t reproduce predictably because they only happen sometimes. That’s what happens when your tests do not start from a “known and pre-configured state”.

Plain JS Dependency Injection

What if I told you all of this pain can be avoided by applying just one technique, no new syntax or tool, just plain JS. Introducing: Dependency Injection (DI).

Wikipedia describes the goal of DI like this:

dependency injection aims to separate the concerns of constructing objects and using them, leading to loosely coupled programs.

It says “constructing objects” because this technique has its origin in object-oriented programming. You don’t need objects (or classes for that matter) to make use of it, we’ll be constructing a function.

This is the function we will be testing:

import axios from "axios";

export function getProducts() {
    return axios.get("/products").then(result => {
        if (result.status === 200) {
            return result.data
        } else {
            return Promise.reject(result);
        }
    });
}

And a Jest test that mocks the Axios module:

import axios from "axios";
import { getProducts } from "./get-products";

jest.mock("axios");

describe("getProducts()", () => {
    it("happy flow", async () => {
        const products = [
            { id: "PRODUCT-1", name: "Product 1" },
            { id: "PRODUCT-2", name: "Product 2" },
        ];
        axios.get.mockResolvedValue({
            status: 200,
            data: products,
        });

        expect(await getProducts()).toEqual(products);
    });

    it("handles unexpected status code correctly", async () => {
        axios.get.mockResolvedValue({
            status: 404,
            data: "Not found",
        });
        const catchMock = jest.fn();

        await getProducts().catch(catchMock);

        expect(catchMock).toHaveBeenCalledWith({
            status: 404,
            data: "Not found",
        });
    });
});

We have to mock the Axios module here because there is no other way to make getProducts() call a different Axios instance. The current implementation is tightly coupled to the default Axios instance.

We can fix that with DI, let’s see what the code looks like:

import defaultAxios from 'axios';

// createGetProducts() is a factory function that takes
// getProducs()'s dependencies as parameters
export function createGetProducts(axios) {
    // By defining the function inside the factory function we can use
    // axios from the factory function instead of the module system.
    return function getProducts() {
        return axios.get('/products').then(result => {
            if (result.status === 200) {
                return result.data;
            } else {
                return Promise.reject(result);
            }
        });
    }
}

// By creating an exported getProducts() function with default dependencies
// you can still import getProducts in other modules like you are used to.
export const getProducts = createGetProducts(defaultAxios);

In the tests we can now use the factory function createGetProducts() to create our own version of getProducts with a mocked Axios instance. No need to get fancy, the mock is also plain JS:

import { createGetProducts } from "./get-products";

describe("getProducts()", () => {
    it("happy flow", async () => {
        const products = [
            { id: "PRODUCT-1", name: "Product 1" },
            { id: "PRODUCT-2", name: "Product 2" },
        ];
        const mockAxios = mockAxiosGet({
            status: 200,
            data: products,
        });
        const getProducts = createGetProducts(mockAxios);

        expect(await getProducts()).toEqual(products);
    });

    it("handles an unexpected status code correctly", async () => {
        const mockAxios = mockAxiosGet({
            status: 404,
            data: "Not found",
        });
        const getProducts = createGetProducts(mockAxios);
        const catchMock = jest.fn();

        await getProducts().catch(catchMock);

        expect(catchMock).toHaveBeenCalledWith({
            status: 404,
            data: "Not found",
        });
    });
});

function mockAxiosGet(response) {
    return {
        get() {
            return Promise.resolve(response);
        },
    };
}

By using DI instead of module mocking:

  • Your code is more loosely coupled
  • Your tests can run in isolation
  • You do not need to know any framework-specific syntax / module magic to inject a mock

Want to learn more? Get the FREE TDD training exercise!

TDD is not so much about the code you end up with, but about the way you got there. The best way to learn TDD, is to pair with an experienced engineer.

I can’t pair with every one of you, but there is something I can do:

Want to see how an experienced engineer uses TDD to build a web frontend and then apply it yourself in the same codebase?

Get the FREE TDD training exercise below.

We’ll go further than the trivial examples you find all over the internet, but without the stress and hassle of a real project. That way you learn things you can actually use in your day job.

We will be implementing a registration flow where users can sign up and confirm their account with a verification code.

You get:

  • A git repository with:
    • the exercise itself
    • the solution
    • a commit for each step of the way
  • 1.5 hour of video:
    • going through the entire exercise from start to finish
    • with explanation of what’s happening and why

No spam, no sharing your data with third parties, unsubscribe at any time.