The jest.mock() escape hatch
Mocking concrete dependencies is a code-smell.
I’m a software engineer living and working in East London. I’m currently helping to build a one-stop-shop for the digitisation of alternative assets over at Daphne. Although once strictly front-end, today I work across the whole stack, including dipping my toes into DevOps and writing Rust & Go.
I see this a lot:
TypeScriptimport { mocked } from 'ts-jest/utils';
import getHttpClient from '../getHttpClient';
jest.mock('../getHttpClient');
const mockedGetHttpClient = mocked(getHttpClient);
This is Jest’s module mocking in action. Pass jest.mock()
a module, be it internal or an NPM package, and Jest will substitute it with a test-double.
It’s very convenient. It’s also awkward and confusing.
TLDR: Make your dependencies explicit. If it needs to be configurable, make it so.
The contrived example
To serve as a demonstration, we’re going to use the Art Institute of Chicago’s public API to display the title of a piece of art: Starry Night and the Astronauts.
Information about an artwork can be found by sending a GET
request to the institute’s API, passing the unique identifier as a URL parameter:
https://api.artic.edu/api/v1/artworks/129884
An assortment of data is returned, including the artwork’s title:
JSON{
"data": {
"title": "Starry Night and the Astronauts"
}
}
I’ll be using React to fetch and display the title, but the principles apply universally.
First pass: the client & the hook
First, we create an axios client to talk to the API:
TypeScript// artClient.ts
import axios from "axios";
const artClient = axios.create({
baseURL: 'https://api.artic.edu/api/v1/artworks',
});
export default artClient;
Then we create a simple hook to do the heavy lifting, conveniently forgetting error handling, loading states and response typings:
TypeScript// useArtworkTitle.tsx
import { useEffect, useState } from "react";
import artClient from "./artClient";
function useArtworkTitle(id: string): string {
const [current, setCurrent] = useState("n/a");
useEffect(() => {
artClient
.get(id)
.then(({ data: { data } }) => {
setCurrent(data.title);
});
}, [id])
return current;
}
The hook is now ready to be used in a simple component, let’s call it <Frame />
:
TypeScript// Frame.tsx
function Frame({ id }: { id: string }) {
const title = useArtworkTitle(id);
return <h1>{title}</h1>;
}
export default Frame;
It’s naive, but functional, and we’re ready to call it a day.
However, when it comes time to test it, we find ourselves backed into a corner:
TypeScript// useArtworkTitle.test.tsx
import { renderHook } from "@testing-library/react-hooks";
import useArtworkTitle from "./useArtworkTitle";
describe("useArtworkTitle", () => {
it("should return the title when the request is successful", async () => {
const title = "Starry Night and the Astronauts";
// No way to swap in a test-double, we're going to be hitting the real API.
const { result, waitForNextUpdate } = renderHook(() => useArtworkTitle("129884"))
await waitForNextUpdate();
expect(result.current).toEqual(title);
});
});
The test passes, but we’re hitting the real API. Since it’s not desirable (or sustainable) to rely on an unmanaged, out-of-process dependency in our test suite, we need to introduce a test-double.
Yet, by using the singleton artClient
, we’ve made it impossible to cleanly do so.
Mocking concrete modules 🙈
Fortunately, Jest lets us monkey-patch a module:
TypeScript// useArtworkTitle.test.tsx
import { mocked } from 'ts-jest/utils'
import { renderHook } from "@testing-library/react-hooks";
import useArtworkTitle from "./useArtworkTitle";
import artClient from "./artClient";
// Substitutes the module for a test double
jest.mock("./artClient");
// Adds mock typings to the module
const mockedClient = mocked(artClient, true);
describe("useArtworkTitle", () => {
it("should return the title when the request is successful", async () => {
const title = "Starry Night and the Astronauts";
mockedClient.get.mockResolvedValue({
data: {
data: { title },
},
});
const { result, waitForNextUpdate } = renderHook(() => useArtworkTitle("129884"))
await waitForNextUpdate();
expect(result.current).toEqual(title)
});
});
The test is uglier, but we’ve managed to retain the cleanliness of our design. It doesn’t look particularly egregious, but sacrifices have been made along the way:
artClient
is now overridden for all modules. Anything which imports it will now be using the test-double, not justuseArtworkTitle
.- If we need to reinstate the original, or mock only a single method, we’ll need to fumble about with
jest.requireActual
. This is a common occurrence if you don’t want to completely replace a dependency. - The test suite now has intimate knowledge of the internals of the SUT.
artClient
was only an implementation detail ofuseArtworkTitle
, but now it’s part of the test.
In the above example it’s easy to let it slide, but in a real codebase these kinds of problems begin to mount. Soon the suite is host to an entire prelude of jest.mock
’s, a set of beforeEach()
calls to reset said mocks, and a set of mock-object assertions that correspond almost exactly to the SUT's internals.
Dependency injection
The solution is not glamorous, it’s dependency injection (which, more often than not, simply means take your dependencies as arguments).
Let’s refactor our example, passing in a type which represents our API client:
TypeScript// useArtworkTitle.tsx
// The dependency our hook needs to function:
// A 'client' which, when given an ID, returns a title.
type ArtClient = (id: string) => Promise<string>;
// A factory which creates a new useArtworkTitle hook
function createUseArtworkTitle(client: ArtClient) {
return function useArtworkTitle(id: string): string {
const [current, setCurrent] = useState("n/a");
useEffect(() => {
client(id)
.then((title) => {
setCurrent(title);
});
}, [id])
return current;
}
}
export default createUseArtworkTitle;
Our test instantly becomes much cleaner:
TypeScript// useArtworkTitle.test.tsx
import { renderHook } from "@testing-library/react-hooks";
import createUseArtworkTitle from "./useArtworkTitle";
describe("useArtworkTitle", () => {
it("should return the title when the request is successful", async () => {
const title = "Starry Night and the Astronauts";
const stubClient = jest.fn().mockResolvedValue(title);
const useArtworkTitle = createUseArtworkTitle(stubClient);
const { result, waitForNextUpdate } = renderHook(() => useArtworkTitle("129884"))
await waitForNextUpdate();
expect(result.current).toEqual(title)
});
});
No more monkey-patching. Dependencies are passed to the createUseArtworkTitle
factory and captured within the closure.
For traditional dependencies this is as far as you need to go. At the start of your application simply initialize your dependencies, pass them to whatever needs them, and then never worry about them again. Only the entry point of the application ever needs to concern itself with the wiring.
However, with React things get slightly more interesting.
React & dependency injection
Let’s revisit our <Frame />
component. Now we’ve got our createUseArtworkTitle
, we need to update our implementation to use the ‘real’ API if we want to see the title again:
TypeScript// Frame.tsx
import createUseArtworkTitle from "./useArtworkTitle";
import artClient from "./artClient";
const useArtworkTitle = createUseArtworkTitle(
// Our axios implementation of ArtClient
(id) => artClient.get(id).then(({ data: { data: { title } } }) => title)
);
function Frame({ id }: { id: string }) {
const title = useArtworkTitle(id);
return <h1>{title}</h1>;
}
export default Frame;
Of course, as soon as it comes time to test it, we’re back at square-one. <Frame />
uses a concrete, hard-coded implementation of ArtClient
. We can push the problem up the tree and pass the client down, but fortunately React already comes with a built-in dependency-injection tool: context.
Instead of passing in dependencies up-front, we can allow useArtworkTitle
to pull them from context.
Off we go:
TypeScript// useArtworkTitle.tsx
type ArtClient = (id: string) => Promise<string>;
// Every out-of-process dependency our hook requires
interface Dependencies {
client: ArtClient
}
export const Context = createContext<Dependencies>({
// Use our axios implementation as the default
client: (id: string) => artClient.get(id).then(({ data: { data: { title } } }) => title),
});
function useArtworkTitle(id: string): string {
const [current, setCurrent] = useState("n/a");
// Pull the ArtClient from the nearest provider
const { client } = useContext(Context);
useEffect(() => {
client(id)
.then((title) => {
setCurrent(title);
});
}, [id, client])
return current;
}
Testing now becomes a breeze:
TypeScript// Frame.test.tsx
import { render, screen } from "@testing-library/react";
import Frame from "./Frame";
import { Context } from "./useArtworkTitle";
describe("Frame", () => {
it("should render the title when the request is successful", async () => {
const title = "Starry Night and the Astronauts";
const stubClient = jest.fn().mockResolvedValue(title);
const deps = { client: stubClient };
render(
<Context.Provider value={deps}>
<Frame id="129884" />
</Context.Provider>
);
const result = await screen.findByText(title);
expect(result).toBeInTheDocument();
});
});
Our hook is similarly easy to update:
TypeScript// useArtworkTitle.test.tsx
import { renderHook } from "@testing-library/react-hooks";
import useArtworkTitle, { Context } from "./useArtworkTitle";
describe("useArtworkTitle", () => {
it("should return the title when the request is successful", async () => {
const title = "Starry Night and the Astronauts";
const stubClient = jest.fn().mockResolvedValue(title);
const contextValue = { client: stubClient };
// Wrap the hook in the provider, passing our stub client as the context
// https://react-hooks-testing-library.com/usage/advanced-hooks#context
const { result, waitForNextUpdate } = renderHook(() => useArtworkTitle("129884"), {
initialProps: { value: contextValue },
wrapper: Context.Provider,
});
await waitForNextUpdate();
expect(result.current).toEqual(title)
});
});
Once nice advantage is that we’ve provided a default implementation. Consumers don’t have to use the factory to create the hook, but can still override its dependencies without resorting to patching the internals.
If this approach looks familiar, it’s probably because it is. Many React libraries use a similar approach, including Redux and Apollo.
An additional hook dependency
Note that we did have to add client
to the dependency array inside our hook:
TypeScriptconst { client } = useContext(Context);
useEffect(() => {
client(id)
.then((title) => {
setCurrent(title);
});
}, [id, client])
In reality these dependencies are likely to be locked in, created once and never change for the lifetime of the application. It’s unlikely the additional hook dependency will cause any re-renders. If the client really did change, you’d likely want it to run again anyway.
If it bothers you, you could instead perform an equality check on the id, or store the client in a ref:
TypeScript// useArtworkTitle.tsx
function useArtworkTitle(id: string): string {
const [current, setCurrent] = useState("n/a");
const deps = useContext(Context);
// Store the client in a ref. The identity of a ref is consistent,
// so there's no need to include it in the dependencies array.
const client = useRef(deps.client);
// Keep the client updated in case there are any changes. This isn't
// required if you're confident it will never change, although is
// a good santity-saver.
useEffect(() => {
client.current = deps.client;
}, [deps]);
useEffect(() => {
client.current(id)
.then((title) => {
setCurrent(title);
});
}, [id])
return current;
}
Testing the client
It’s true that, since we’re testing a little further from the metal, there is the potential for issues within our real ArtClient
.
It usually makes sense to keep clients and repositories as logic-free as possible, but inevitably there will be some assumptions that will be worth testing, such as deserialization and/or assumptions about the schema.
One potential solution for ArtClient
would be to simply mock out the network using MSW or a similar tool. Here’s an example:
TypeScript// artClient.tsx
// Let's create a factory for the client.
// This makes it clearer that we're not hitting the real API.
function createClient(baseURL: string) {
const client = axios.create({ baseURL });
return {
getTitle: (id: string) =>
client.get(id).then(({ data: { data: { title } } }) => title)
};
}
TypeScript// artClient.test.tsx
import { rest } from "msw"
import { setupServer } from 'msw/node'
import createClient from "./artClient";
describe("artClient", function () {
describe("getTitle", () => {
it("should extract the title from a successful payload", async () => {
const title = "Starry Night and the Astronauts";
// Intercept all requests
const server = setupServer(
rest.get("*", (_, res, ctx) => res(ctx.json({
data: { title },
}))),
);
server.listen();
const client = createClient("http://test.test");
const result = await client.getTitle("129884");
expect(result).toEqual(title);
});
});
});
Once again we’re back to implicit monkey-patching, but at least this time we’ve isolated the suite which deals with the dependency. MSW will also pass through any requests which don’t match one of the handlers passed to setupServer
, so it’s less destructive than mocking out a module.
Of course, an even cleaner (if less convenient) alternative is to spin up an actual stub server, passing the address to createClient
.
Dependency injection & TDD
One of the biggest hurdles to overcome when adopting TDD is that it’s initially very hard to wrap your head around how you could write a reliable test without first knowing the implementation. After all, mocking globals and module plumbing requires you to have a solid grasp of the SUT's internals.
Contrast that with the approach described above. You are forced to immediately define your external, out-of-process dependencies. You describe dependencies in the terms of your requirements:
I need an
ArtClient
, which I will pass anID
and which will return the title.
Defining your dependencies and contracts in this way ensures it becomes part of the design-process. By the end, you’ve described everything you need to accomplish the task, and created any appropriate abstractions along the way.
Working in this way makes TDD considerably easier.
Pass it on
Regardless of your library or tool of choice, there will (nearly) always be a better method of introducing test-doubles than jest.mock
.
Redux-Saga has its own context, MobX-State-Tree allows you to pass dependencies through the tree, Vue has provide
and inject
. Even if your tool doesn’t come with a built-in solution, there is always the option of simply passing down dependencies.
There are exceptions to the rule, but it should never be your first choice.