DEV Community

Cover image for Mocking ESM in 2026: Vitest, Bun, and Node's mock.module
Gabriel Anhaia
Gabriel Anhaia

Posted on

Mocking ESM in 2026: Vitest, Bun, and Node's mock.module


You open a file called payments.test.ts. The first thing you
need to do is replace one function on a module the file under
test imports. In CJS this was a one-liner: reassign the property
on the required object and move on. In ESM that line throws.
Module namespace exports are read-only. You cannot patch them.
You cannot reassign them. The runtime will let you compile, then
slap your wrist at the moment the test tries to write.

So you reach for the runner. And here the answer depends on
which runner you reached for. Vitest has vi.mock. Bun has
mock.module. Node 24 has mock.module on the built-in
node:test runner, behind no flag now, and a different shape
again. Three surfaces, all doing the same conceptual thing,
none of them are quite drop-in compatible.

Below is the working set of patterns that survive across all
three runners, plus the moves to retire because in 2026 they
belong in a museum.

What broke

CJS gave you mutable exports. The module.exports object was
just an object. Tests reassigned its properties, code under test
re-read them on the next call, life was easy. Two patterns came
out of that:

const fs = require("fs");
fs.readFile = jest.fn();
Enter fullscreen mode Exit fullscreen mode
jest.mock("../db", () => ({ query: jest.fn() }));
Enter fullscreen mode Exit fullscreen mode

The first does not work in ESM at all. The namespace object
returned by import * as fs from "fs" is sealed. The second
looks like it works, but it only does because Jest used to
run ESM under a CJS transformer that dropped you back into a
mutable bag of exports. Native ESM does not give you that bag.

What you can do, what every modern runner agrees on, is replace
the module in the loader's cache before the consumer imports
it
. The consumer's import statement reads the cache. If the
cache has a different module sitting there, the consumer gets
the different module. The mechanism for getting your replacement
into the cache is what differs between Vitest, Bun, and Node.

Two ground rules fall out of this:

  1. The mock has to be installed before the import. Either the runner hoists your mock above the imports, or you defer the import to after you call the mock function. There is no third option.
  2. You replace the whole module. You do not "stub one property." You return a new object that has whatever shape the consumer expects, and you keep the parts you do not want to fake by re-importing the real module and spreading it.

Hold those two rules and the rest is dialect.

Pattern 1: hoisted module mock

The most common shape. Replace the module up front, before any
test runs, and let every test in the file see the replacement.

Here is a small library. notifier.ts calls sendEmail from
./mailer:

// src/mailer.ts
export async function sendEmail(
  to: string, subject: string, body: string
): Promise<{ id: string }> {
  const res = await fetch("https://mail.test/v1/send", {
    method: "POST",
    body: JSON.stringify({ to, subject, body }),
  });
  return res.json();
}

// src/notifier.ts
import { sendEmail } from "./mailer";

export async function notifyOrderShipped(
  email: string, orderId: string
) {
  return sendEmail(
    email,
    "Your order has shipped",
    `Order ${orderId} is on the way.`
  );
}
Enter fullscreen mode Exit fullscreen mode

You want to test notifyOrderShipped without hitting the
network. The fake replaces the whole ./mailer module.

Vitest. The mock call is hoisted above the imports by the
transformer. You write it as if it were imperative; Vitest moves
it for you:

import { describe, it, expect, vi } from "vitest";

vi.mock("./mailer", () => ({
  sendEmail: vi.fn(async () => ({ id: "msg_1" })),
}));

import { notifyOrderShipped } from "./notifier";
import { sendEmail } from "./mailer";

describe("notifyOrderShipped", () => {
  it("dispatches the email", async () => {
    const out = await notifyOrderShipped(
      "a@b.test", "ord_42"
    );
    expect(out.id).toBe("msg_1");
    expect(sendEmail).toHaveBeenCalledTimes(1);
  });
});
Enter fullscreen mode Exit fullscreen mode

The two things that look wrong but are not: the vi.mock line
sits below the imports in the source, and the second import
of sendEmail brings in the mocked function, not the real
one. Vitest rewrites the file so the mock is registered before
either import statically resolves.

Bun. Same idea, different surface. mock.module is a real
runtime call, not a hoisted directive, so you put it before any
dynamic read of the module. For a fully static-import setup,
the safest place is a preload file:

// test-setup.ts
import { mock } from "bun:test";

mock.module("./src/mailer", () => ({
  sendEmail: async () => ({ id: "msg_1" }),
}));
Enter fullscreen mode Exit fullscreen mode

Then run with bun test --preload ./test-setup.ts. The test
file itself stays plain:

import { describe, it, expect } from "bun:test";
import { notifyOrderShipped } from "./notifier";

describe("notifyOrderShipped", () => {
  it("dispatches the email", async () => {
    const out = await notifyOrderShipped(
      "a@b.test", "ord_42"
    );
    expect(out.id).toBe("msg_1");
  });
});
Enter fullscreen mode Exit fullscreen mode

If you call mock.module inside the test file after a static
import of the consumer, the consumer has already read the real
module. You either preload or you switch the consumer-side
import to a dynamic one (pattern 3 below).

Node 24, node:test. The shape is the third dialect.
mock.module lives on the mock object exposed by node:test,
the export shape uses a namedExports field, and the consumer
of the mocked module must be loaded by dynamic import after
the mock is registered:

import { test, mock } from "node:test";
import assert from "node:assert/strict";

test("notifyOrderShipped dispatches the email", async () => {
  mock.module("./src/mailer.ts", {
    namedExports: {
      sendEmail: async () => ({ id: "msg_1" }),
    },
  });

  const { notifyOrderShipped } = await import(
    "./src/notifier.ts"
  );

  const out = await notifyOrderShipped(
    "a@b.test", "ord_42"
  );
  assert.equal(out.id, "msg_1");
});
Enter fullscreen mode Exit fullscreen mode

Run it with node --test --experimental-test-module-mocks
(flag name as of Node 24 LTS). The module-mocks loader is still
flagged at the time of writing, even though node:test itself
is stable. Check
the test runner docs
on the version you ship before you wire it up. The field name
and the flag have changed; check the release notes for the
version you ship.

The mental model is the same across all three: replace the
module entry in the loader cache before the consumer reads it.

Pattern 2: per-test partial mock

The second-most-common shape. You want one export faked, the
other ten left alone. The trick is importActual (Vitest), a
spread of the real module (Bun and Node), and the same hoisting
discipline as before.

A module with two functions:

// src/clock.ts
export function now(): Date {
  return new Date();
}

export function format(d: Date): string {
  return d.toISOString();
}
Enter fullscreen mode Exit fullscreen mode

You want now frozen at a known instant. You want format
real. The fake imports the real module and overrides one field.

Vitest:

import { describe, it, expect, vi } from "vitest";

vi.mock("./clock", async (importOriginal) => {
  const real = await importOriginal<
    typeof import("./clock")
  >();
  return {
    ...real,
    now: () => new Date("2026-04-29T00:00:00Z"),
  };
});

import { now, format } from "./clock";

describe("clock", () => {
  it("returns the frozen now()", () => {
    expect(now().toISOString()).toBe(
      "2026-04-29T00:00:00Z"
    );
  });

  it("still uses the real format()", () => {
    expect(format(new Date("2026-01-01T00:00:00Z")))
      .toBe("2026-01-01T00:00:00.000Z");
  });
});
Enter fullscreen mode Exit fullscreen mode

importOriginal is the callback Vitest passes to the factory;
it returns the real module with proper types when you
parameterize it as shown. The factory runs once per file; the
spread keeps every export the test did not name explicitly.

Bun:

// test-setup.ts
import { mock } from "bun:test";

const real = await import("./src/clock");
mock.module("./src/clock", () => ({
  ...real,
  now: () => new Date("2026-04-29T00:00:00Z"),
}));
Enter fullscreen mode Exit fullscreen mode

Bun's mock.module factory does not get an importOriginal
argument. You import the real module yourself, into the
preload, and spread it. Same outcome. Top-level await is fine
in a Bun preload; Bun's loader supports it natively.

Node node:test:

import { test, mock } from "node:test";
import assert from "node:assert/strict";

test("frozen now, real format", async () => {
  const real = await import("./src/clock.ts");
  mock.module("./src/clock.ts", {
    namedExports: {
      ...real,
      now: () => new Date("2026-04-29T00:00:00Z"),
    },
  });

  const { now, format } = await import(
    "./src/clock.ts"
  );
  assert.equal(
    now().toISOString(), "2026-04-29T00:00:00Z"
  );
  assert.equal(
    format(new Date("2026-01-01T00:00:00Z")),
    "2026-01-01T00:00:00.000Z"
  );
});
Enter fullscreen mode Exit fullscreen mode

Pattern: import the real module, override the fields you want
fake, spread the rest. The runner specifics fade once you lock
onto the spread.

Pattern 3: dynamic-import-based mocking

You ship code that does code-splitting. The function under test
contains an await import("./heavy-thing"). The natural fit is
to install the mock, then trigger the dynamic import, then
assert.

// src/router.ts
export async function handle(name: string) {
  if (name === "billing") {
    const mod = await import("./handlers/billing");
    return mod.run();
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The test, in Vitest:

import { describe, it, expect, vi } from "vitest";

vi.mock("./handlers/billing", () => ({
  run: vi.fn(async () => "billed"),
}));

import { handle } from "./router";

describe("handle", () => {
  it("dispatches to the billing handler", async () => {
    expect(await handle("billing")).toBe("billed");
  });
});
Enter fullscreen mode Exit fullscreen mode

In Bun, the same shape works the same way as long as the mock
is installed (preload or before the dynamic import):

import { describe, it, expect, mock } from "bun:test";

mock.module("./src/handlers/billing", () => ({
  run: async () => "billed",
}));

import { handle } from "./src/router";

describe("handle", () => {
  it("dispatches to the billing handler", async () => {
    expect(await handle("billing")).toBe("billed");
  });
});
Enter fullscreen mode Exit fullscreen mode

Static imports hoist; the mock.module call runs before
handle is invoked, and the await import(...) inside handle
reads the now-mocked entry. That is why the example works even
though mock.module appears below the import in source order.

In Node node:test, dynamic-import code is the easiest case
because the runtime is already ordering the call after the
mock:

import { test, mock } from "node:test";
import assert from "node:assert/strict";

test("router dispatches to billing", async () => {
  mock.module("./src/handlers/billing.ts", {
    namedExports: { run: async () => "billed" },
  });

  const { handle } = await import("./src/router.ts");
  assert.equal(await handle("billing"), "billed");
});
Enter fullscreen mode Exit fullscreen mode

The dynamic-import shape sidesteps every hoisting question. The
mock is registered, then the consumer code reaches into the
loader, then it gets the fake. This is the cleanest pattern of
the three. If you can keep rarely-used branches behind dynamic
imports, your test suite is the same across runners.

Three patterns to retire

A short list of things that do not work in 2026 ESM, and that
old blog posts still tell you to do.

Stubbing require. proxyquire, mock-require, and the
jest.mock flow that depends on require.cache only work
because they are patching CJS resolution. ESM does not have
require.cache. If your test file is ESM and the consumer is
ESM, these libraries are a no-op or worse: they install a CJS
mock that the ESM loader never reads.

Monkey-patching mutable exports.

import * as mailer from "./mailer";
(mailer as any).sendEmail = vi.fn();
Enter fullscreen mode Exit fullscreen mode

Throws in native ESM (Node, Bun, Deno). The namespace object is
sealed. You will see TypeError: Cannot assign to read only
property 'sendEmail' of object '[object Module]'
. Some Vitest
configs used to let this slide via a CJS-shim transformer; that
path is gone in Vitest 2+. If your existing tests rely on this,
the migration is to one of the three patterns above.

Mutating the imported binding. Same problem, different
syntax:

import { sendEmail } from "./mailer";
sendEmail = vi.fn(); // syntax error
Enter fullscreen mode Exit fullscreen mode

ESM imports are bindings, not assignments. The compiler refuses.

A rule that survives all three

Most of the runner-specific pain disappears the moment your
function takes its collaborators as parameters. A notifyOrderShipped
that accepts the sendEmail function as an argument needs no
module mock at all. You pass a fake. The places that genuinely
need module mocking are the ones where you cannot reach the
seam: framework-loaded modules, deeply nested helpers, dynamic
imports you do not own.

For those, hold the three patterns above and pick whichever
runner the rest of your stack uses. Vitest if your tooling is
Vite-aligned. Bun if your runtime is Bun. node:test if your
constraint is "no test-runner dependency in production." Pick
the runner your stack already uses; the patterns above carry
over.


If this was useful

Module systems, ESM-CJS interop, the loader cache, and the
contortions test runners go through to mock around them are
exactly the ground TypeScript in Production covers in long
form. The build chapter is the one that maps a single tsc
invocation to "what does this package look like under Node, Bun,
Deno, and a bundler"; the testing chapter is the long version
of this post, including coverage, fixtures, and the matrix that
catches the runner-divergence bugs before a downstream user
files an issue.

If you want the ground floor before the production layer,
TypeScript Essentials is the entry point. If you want the
type system depth that makes typed mocks (vi.mocked<T>,
MockInstance<F>) compose without as any, The TypeScript
Type System
is the deep dive.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)