Comparing jest.mock and Dependency Injection in TypeScript
keithbro
Posted on March 24, 2021
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:
- Validate the request payload
- Call some business logic
- Prepare the response payload
- 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 });
}
};
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:
- What does our controller do if
createPerson
throws an error? - 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",
});
});
});
});
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"
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,
});
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 });
}
};
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",
});
});
});
});
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",
});
});
});
});
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
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.
Posted on March 24, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.