Decision Record: Mocking in Jest tests

This is a lightly edited copy of an Architecture Decision Record I wrote at work and share here in public.

Date: 2023-09-06

Background

We use a lot of mocking in tests written with Jest whenever modules need to leave the JavaScript system barrier. That is, whenever we make network requests, access the location, microphone or camera or some other device interaction, we have to mock those interactions. The rest of this document will call these “system interactions”.

The approach to mocking system interactions currently varies a lot, since we have no convention or agreement around these.

Reasons for Decision

To make Jest tests easier to write and read and increase consistency, we should decide on guidelines for mocking system interactions.

Options

Option 1: Focus on unit testing

Whenever a module (a React component, some other functions) depends on other modules that have system interactions, we mock those direct dependencies, so that the module under test only depends on pure functions or mocks. We mock as closely as possible to the tested module. That could mean jest.mock('@tanstack/react-query') (when testing a custom hook using react-query) or jest.mock('../hooks/useGetUserData') (when testing a component that uses a custom hook).

Since we mock a lot of our own code, we’ll use Manual Mocks correctly to reuse mocks and avoid duplicating mocks many times. Relates to ‘Bonus option 2’ below.

This doesn’t mean that we would mock all dependencies of a module – that would lead to an excessive amount of mocking with little additional value. The focus is on mocking those dependencies that directly or indirectly involved system interactions.

  • Pros:
    • Tests focus on one module and don’t break when dependencies change
  • Cons:
    • Need to write a test for each module, since there is no indirect coverage
    • We’d write a lot of mocks, even for our own code, which could hide actual bugs if the mocks don’t reflect the actual module correctly

Option 2: Focus on integration testing

Whenever a module (a React component, some other functions) depends on other modules that have system interactions we mock the system APIs, like using nock to mock the network requests or mocking react-native-permissions. If a component uses a custom hook that uses react-query to fetch some data, we’d mock the request for “fetch some data”, not the hook or react-query.

Tests without any system interactions won’t need any mocking, for example a pure function can be covered with a neat unit test without mocks.

  • Pros:
    • Increased test coverage of our own code – a single test can cover more than just the module under test
    • Since we only mock system interactions on the “outside”, we can reuse a lot of mocks and don’t need to write them so often. See ‘Bonus option 2’ below for more details.
    • Mocking network requests is more precise than mocking react-query or custom hooks – tests get closer to what the user does, with better runtime than e2etests
  • Cons:
    • Tests can break (false negatives) when imported modules change

Option 3: Combine 1 + 2, label clearly

We use both approaches of mocking outlined as option 1 and 2 above, but clearly label those tests.

// mocks direct dependencies if system interactions are involved
describe('useRecording unit tests', () => {
// mocks only system interactions, not direct dependencies
describe('AssignRecordingGroupModal integration tests', () => {

Within those categories, we apply the rules above, mocking system interactions as close as possible (unit tests) or as far away as possible (integrations), and don’t mix it up.

  • Pros:
    • More flexibility in how we write tests, leaves this decision up to the author
    • A bit more consistency over the status quo, since we at least introduce definitions of unit and integration tests and how they are allowed to deal with system interactions
  • Cons:
    • Need to decide between unit and integration test for each module with system interactions, and potentially defend that decision
    • All the cons of both options

Bonus option 1: Limit jest API usage for mocking

We agree to always use jest.mock() and to forbid usage of jest.spyOn. This is mostly based on various problems we’ve found related to jest.spyOn when trying to switch from babel to swc.

For creating mocks that need to mock a module, but only customize some of it, we try to use jest.createMockFromModule(moduleName)

Bonus option 2: Convention for local vs global mocks

If a mock for a library (module loaded from node_modules) is needed in more than two test files, we move it to __mocks__ (in the root folder). The file name must match the module name used for importing the module, like __mocks__/lodash.js to mock lodash. If named correctly, these mocks are automatically loaded! There’s more details about this in the Jest docs – note the exception about built-in nodejs modules.

Since those mocks are automatically loaded, we try to (incrementally) replace all mocks from jest.config.js‘s setupFilesAfterEnv property and also replace them from setup.jest.js.

Results

Adopt Option 2 (as above) along with the bonus options.

This is the best option to increase test coverage and consistency while keeping the number of mocks low.

We can revisit this decision if it turns out that this focus on integration tests has more or larger drawbacks than estimated here. We could then switch to Option 1 or 3, or refine our approach with Option 2.

Sources

-Jörn