A checkout service rarely lives alone. The checkout module imports other modules - analytics that emits a purchase event, and a payment gateway that moves real money. A module here is a single file with exports, and a module mock replaces all of its exports with fakes for the duration of the test file. Calling jest.mock('./analytics') tells Jest: whenever anyone imports this path, hand back stubs instead of the real code. The temptation is obvious - a test needs no real network call, and one line switches off an entire file.
The naive move is to mock away anything in the way and enjoy the isolation. It feels natural, because babel-jest hoists jest.mock above every import in the file, so the replacement is in place before the module under test even reaches for its dependency. It looks like you got clean isolation for free.
It breaks quietly. A module mock is action at a distance: it changes the behavior of a file you never even mention in the test, and tracing a strange result back to it gets hard. The mock is brittle - it ties the test to the shape of the import graph rather than to behavior, so any path refactor breaks tests without changing anything real. And the mock drifts: the real module grows new functions the stub knows nothing about, and it silently returns undefined for them.
For domain logic there is a better way - dependency injection. Instead of a module pulling the gateway in through import, you pass the gateway as an argument or through a constructor: a function checkout(order, { gateway, analytics }) receives its dependencies from outside. In a test you simply hand it a jest.fn in place of the gateway - no magic, no hoisting, full type support. Code designed for injection states its boundaries honestly, right in the signature.
A module mock earns its place where injection cannot reach: an external service SDK, a platform runtime API, a heavy boundary such as the file system or a third-party library you do not control. Here the point is not to switch the whole module off. jest.requireActual loads the real module, and you spread its exports and replace only one function - trackPurchase becomes a stub while everything else stays live.
With ES modules the rules differ. The jest.mock hoisting works through a Babel transform and does not fire on native ESM, so for those there is jest.unstable_mockModule. It is not hoisted, which makes order critical: declare the mock first, and only then pull in the module under test through a dynamic await import. Import checkout before the mock and it binds to the real gateway - the replacement arrives too late.
A separate trap is the global automock: automock enabled in the config, or a shared mock in mocks that auto-fakes a package across every test. Such a rule acts invisibly, and six months on nobody on the team remembers why a module behaves differently in tests than in production. The cost of a module mock is exactly this opacity - the wider the replacement, the harder it is to trust a green run.
The classic failure looks harmless. The payment gateway stub returns a hardcoded { id: 'pay-1' }, the test is green, but in production the gateway changed its response shape and the code breaks on a field the stub never had. Keep the replacement narrow, reset it between tests through resetMocks in the config or clearMocks, and treat a module mock as a last resort, not the first tool you reach for.
jest.mock('./analytics', () => {
const actual = jest.requireActual('./analytics')
return {
...actual,
trackPurchase: jest.fn(),
}
})jest.unstable_mockModule('./gateway.js', () => ({
charge: jest.fn().mockResolvedValue({ id: 'pay-1' }),
}))
const { checkout } = await import('./checkout.js')