DEV Community

loading...
Cover image for Comparing jest.mock and Dependency Injection in TypeScript

Comparing jest.mock and Dependency Injection in TypeScript

keithbro profile image keithbro ・7 min read

This post compares two strategies for mocking dependencies in your code for testing purposes. The example shown here focuses on a controller in Express, but the principles can be applied more widely.

A controller usually has some logic of it's own. In our simplified example, it needs to:

  1. Validate the request payload
  2. Call some business logic
  3. Prepare the response payload
  4. Respond

The controller code might look like this:

import { Request, Response } from "express";
import { CreatePersonReqBody, CreatePersonResBody } from "./api_contract";
import { createPerson } from "./domain";

export const createPersonAction = (
  req: Request<{}, CreatePersonResBody, CreatePersonReqBody>,
  res: Response<CreatePersonResBody>
) => {
  // Validate request payload
  if (!req.body.name) {
    res.status(400).json({ error: "name is required" });
    return;
  }

  try {
    // Call inner layer, which may be non-deterministic
    const person = createPerson({
      name: req.body.name,
      favouriteColour: req.body.favouriteColour,
    });

    // Build response payload
    const personPayload = { data: person, type: "person" } as const;

    // Respond
    res.json(personPayload);
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
};
Enter fullscreen mode Exit fullscreen mode

To test this code in isolation, we can mock the call to createPerson. That will allow us to focus solely on the responsibilities of this function. createPerson will have concerns of its own, and will likely hit a database or another API. Mocking the call to createPerson will keep our unit test running fast and predictably.

For the purposes of this example, we'd like to test two scenarios:

  1. What does our controller do if createPerson throws an error?
  2. What does our controller do in the happy path?

One option is to use jest.mock to fake the implementation of createPerson. Let's see what that looks like:

import { getMockReq, getMockRes } from "@jest-mock/express";
import { createPersonAction } from "./controller";
import { ICreatePersonData, IPerson, createPerson } from "./domain";

jest.mock("./domain", () => ({
  createPerson: jest
    .fn<IPerson, ICreatePersonData[]>()
    .mockImplementation((data) => ({ id: 1, name: data.name })),
}));

describe("controller", () => {
  beforeEach(() => jest.clearAllMocks());

  describe("createPerson", () => {
    it("responds with 400 if the colour is invalid", () => {
      (createPerson as jest.Mock).mockImplementationOnce(() => {
        throw new Error("Invalid Colour");
      });

      const req = getMockReq({
        body: { name: "Alan", favouriteColour: "rain" },
      });
      const { res } = getMockRes();

      createPersonAction(req, res);

      expect(createPerson).toHaveBeenCalledWith({
        name: "Alan",
        favouriteColour: "rain",
      });
      expect(res.status).toHaveBeenCalledWith(400);
      expect(res.json).toHaveBeenCalledWith({ error: "Invalid Colour" });
    });

    it("adds the type to the response payload", () => {
      const req = getMockReq({ body: { name: "Alice" } });
      const { res } = getMockRes();

      createPersonAction(req, res);

      expect(res.json).toHaveBeenCalledWith({
        data: { id: 1, name: "Alice" },
        type: "person",
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Observations

It's simple

jest.mock lets us choose the file we want to fake, and provide an implementation. Once the code is written it's clear to understand the intention.

We're bypassing TypeScript

jest.mock has no knowledge of what it's mocking or what type constraints the implementation should adhere to. Similarly when we want to check if our spy was called, TypeScript doesn't know that this is a jest object. This is why we have to cast the function as jest.Mock.

Shared state and mutation

The fake implementation defined at the top is shared across all tests in the file. That means that spied calls to the fake implementation are shared across tests. So if we want to spy on our fake implementation, and be sure that we're only dealing with calls from each individual test, we need to remember to clearAllMocks before every test.

Furthermore, when we want to override the fake behaviour for an individual test, we need to mutate the overall mock and remember to use mockImplementationOnce instead of mockImplementation. If we forget, the new implementation will be present for the next test.

Strange behaviour with custom error classes!

I ran in to some odd behaviour when I tried to fake an implementation that threw an error from a custom error class. Perhaps this was human error on my part but I just couldn't figure it out. The error I'm getting is:

"domain_1.InvalidColourError is not a constructor"
Enter fullscreen mode Exit fullscreen mode

I'm not sure what's going on here - if you know / have a solution please comment below! If you know of ways to overcome any of the other issues, also let me know!

As the title of this post suggests, there is an alternative approach to jest.mock - Dependency Injection. Dependency Injection is a fancy way of saying that we're going to pass in functions that we want to call in our application code (instead of hard coding them). This gives a first-class way of swapping out behaviour as desired.

To enable this in our test, instead of calling jest.mock, we're going to use a utility function that is so small that we can write it ourselves. Don't worry if you don't understand it and feel free to skip over it:

export const inject = <Dependencies, FunctionFactory>(
  buildFunction: (dependencies: Dependencies) => FunctionFactory,
  buildDependencies: () => Dependencies
) => (dependencies = buildDependencies()) => ({
  execute: buildFunction(dependencies),
  dependencies,
});
Enter fullscreen mode Exit fullscreen mode

In short, it returns an object with an execute function that lets you call your controller action, and a dependencies object, which contains the mocks (useful when you want to spy on your calls).

To make use of this in our test, we need to make one small change to our controller:

import { Request, Response } from "express";
import { createPerson } from "./domain";
import { CreatePersonReqBody, CreatePersonResBody } from "./api_contract";

export const buildCreatePersonAction = (dependencies = { createPerson }) => (
  req: Request<{}, CreatePersonResBody, CreatePersonReqBody>,
  res: Response<CreatePersonResBody>
) => {
  // Validate request payload
  if (!req.body.name) {
    res.status(400).json({ error: "name is required" });
    return;
  }

  try {
    // Call inner layer, which may be non-deterministic
    const person = dependencies.createPerson({
      name: req.body.name,
      favouriteColour: req.body.favouriteColour,
    });

    // Build response payload
    const personPayload = { data: person, type: "person" } as const;

    // Respond
    res.json(personPayload);
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
};
Enter fullscreen mode Exit fullscreen mode

Did you spot the difference?

The only change here is that our exported function is a higher-order function, i.e. it's a function that returns another function. This allows us to optionally pass in our dependencies at runtime. If we don't pass anything in, we get the real production dependency by default. The function we get back is the express controller action, with any dependencies now baked in. Everything else is exactly the same.

Now for the test:

import { getMockReq, getMockRes } from "@jest-mock/express";
import { buildCreatePersonAction } from "./controller_w_di";
import { ICreatePersonData, IPerson, InvalidColourError } from "./domain";
import { inject } from "./test_utils";

const buildAction = inject(buildCreatePersonAction, () => ({
  createPerson: jest
    .fn<IPerson, ICreatePersonData[]>()
    .mockImplementation((data) => ({ id: 1, name: data.name })),
}));

describe("controller", () => {
  describe("createPerson", () => {
    it("responds with 400 if the colour is invalid", () => {
      const req = getMockReq({
        body: { name: "Alan", favouriteColour: "rain" },
      });
      const { res } = getMockRes();

      const { dependencies, execute } = buildAction({
        createPerson: jest
          .fn()
          .mockImplementation((data: ICreatePersonData) => {
            throw new InvalidColourError();
          }),
      });

      execute(req, res);

      expect(dependencies.createPerson).toHaveBeenCalledWith({
        name: "Alan",
        favouriteColour: "rain",
      });
      expect(res.status).toHaveBeenCalledWith(400);
      expect(res.json).toHaveBeenCalledWith({ error: "Invalid Colour" });
    });

    it("adds the type to the response payload", () => {
      const req = getMockReq({ body: { name: "Alice" } });
      const { res } = getMockRes();

      buildAction().execute(req, res);

      expect(res.json).toHaveBeenCalledWith({
        data: { id: 1, name: "Alice" },
        type: "person",
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Observations

jest.mock replaced by inject

As we mentioned, instead of jest.mock we have an inject function which wires up the fake dependency for us.

No shared state or mutation

There's no need to clear any mocks, because we generate a new injected action each time. We can use mockImplementation or mockImplementationOnce as we please as the scope is limited to the test. Each test case has it's own fresh version of the controller action, it's dependencies and mocks. Nothing is shared.

Fully type-safe

Because we're dealing with functions and arguments instead of overriding modules, everything is type checked. If I forgot to provide an id in my fake implementation, TypeScript will tell me.

No custom error class issues

I didn't see the same issues with the custom error class that I saw with the jest.mock approach. It just worked. Again, perhaps this is human error. Please comment below if you know what's going on here.

Less familiar pattern

Developers who are used to seeing jest.mock might be confused by the inject call. That said, the differences in usage compared to the jest.mock version are minimal. With this method we're passing a function and an implementation rather than a string (containing the module) and an implementation.

Conclusion

Personally I think there are nice benefits to using the dependency injection style of mocking. If you're not using TypeScript, the benefits are less, but you still have the shared state aspects to worry about. I've seen it lead to strange test behaviour and flakiness in the past that can be hard to track down.

Dependency Injection is a useful pattern to be familiar with. When used in the right spots, it can help you write code that is loosely coupled and more testable. It's a classic pattern in software development, used in many languages, and so it's worthwhile knowing when and how to use it.

A final shout out goes to the authors of @jest-mock/express - a very useful library that let's you stub your Express requests and responses in a type-safe way. Kudos!

The full code is available here.

Update!

A third option exists: jest.spyOn!

With no need for the higher-order function in the controller, your test can look like:

import { getMockReq, getMockRes } from "@jest-mock/express";
import { createPersonAction } from "./controller";
import * as Domain from "./domain";

describe("controller", () => {
  describe("createPerson", () => {
    beforeEach(() => {
      jest.clearAllMocks();
      jest.spyOn(Domain, "createPerson").mockImplementation((data) => {
        return { id: 1, name: data.name };
      });
    });

    it("responds with 400 if the colour is invalid", async () => {
      jest.spyOn(Domain, "createPerson").mockImplementationOnce(() => {
        throw new Domain.InvalidColourError();
      });
      const req = getMockReq({
        body: { name: "Alan", favouriteColour: "rain" },
      });
      const { res } = getMockRes();

      createPersonAction(req, res);

      expect(Domain.createPerson).toHaveBeenCalledWith({
        name: "Alan",
        favouriteColour: "rain",
      });
      expect(res.status).toHaveBeenCalledWith(400);
      expect(res.json).toHaveBeenCalledWith({ error: "Invalid Colour" });
    });

    it("adds the type to the response payload", async () => {
      const req = getMockReq({ body: { name: "Alice" } });
      const { res } = getMockRes();

      createPersonAction(req, res);

      expect(res.json).toHaveBeenCalledWith({
        data: { id: 1, name: "Alice" },
        type: "person",
      });
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

Observations

It's simple

It's pretty clear what's going on. Familiar patterns.

TypeScript is partially supported

We do get type support when specifying a fake implementation. But TypeScript doesn't know that Domain.createPerson is a mock object, so if we wanted to inspect the calls we'd have to do:

(Domain.createPerson as jest.Mock).mock.calls
Enter fullscreen mode Exit fullscreen mode

We can get around this by storing the return value of mockImplementation but this becomes a little untidy if you're doing this in a beforeEach.

State is shared

State is shared across tests so we still need to clearAllMocks in our beforeEach.

No issue with custom error classes

The custom error class issue did not occur with this approach.

Final Conclusion

In my opinion jest.spyOn is a better option than jest.mock but still not as complete a solution as dependency injection. I can live with the TypeScript issue as it's minor, but shared state and tests potentially clobbering each others' setup is a big no.

Discussion (0)

pic
Editor guide