The jest.mock() escape hatch

Mocking concrete dependencies is a code-smell.

Published
Last updated
Photo of Jay in Bazel

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:

Logo for TypeScriptTypeScript
import { 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.

Starry Night and the Astronauts, a painting by Alma Thomas

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:

Logo for JSONJSON
{ "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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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 />:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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 just useArtworkTitle.
  • 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 of useArtworkTitle, 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
const { 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:

Logo for TypeScriptTypeScript
// 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:

Logo for TypeScriptTypeScript
// 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) }; }
Logo for TypeScriptTypeScript
// 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 an ID 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.

← Archive