Skip to content

bun test: register a module mock under the import path and the require() path of its name - #48467

Draft
robobun wants to merge 8 commits into
mainfrom
robobun/521f4b5c/mock-dual-package-require
Draft

robobun wants to merge 8 commits into
mainfrom
robobun/521f4b5c/mock-dual-package-require

Conversation

@robobun

@robobun robobun commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A mock of a package with one file for import and another for require reaches import only: require=real cjs import()=factory. uuid and axios are such packages.
  • resolveModuleMockSpecifier() (src/jsc/bindings/BunPlugin.cpp:3885) resolves the name once, as an import. require() resolves to the other path and finds no mock.
  • So jest.requireActual("pkg") returns the ES module. Jest returns the CommonJS file.

Fix

  • Bun__resolveForImportAndRequire resolves a name both ways. A mock keeps both paths, and addModuleMock() / removeModuleMock() keep its two table entries together.
  • require(), import and the loaders do not change: their exact-key lookups now find the one mock.
  • Each function loads the original of its kind: jest.requireActual the require file, vi.importActual the import file.
  • Verified: 18 new tests in test/js/bun/test/mock/mock-module.test.ts fail on main. Its 314 older tests and plugins.test.ts pass.

Background

Downsides

  • A mock call resolves its name twice: about +7,300 instructions for a relative file, +16,300 for a package. Release .text: +8,576 bytes.
  • jest.requireActual("pkg") of such a package now returns the CommonJS exports.
  • bun test --aot still loads the real file where the build resolved require("pkg"): 18 lines on its known list.
Notes

Origin. There is no GitHub issue. The repro comes from a comparison of bun test with Jest 30.5.2 and Vitest 5.1.0 on one small package.

Repro. node_modules/zz-dual/package.json has "exports": { ".": { "import": "./esm.mjs", "require": "./cjs.cjs" } }. esm.mjs exports which = "real esm" and cjs.cjs exports which: "real cjs".

import { mock, test } from "bun:test";
mock.module("zz-dual", () => ({ which: "factory" }));
test("dual package", async () => {
  const ns = await import("zz-dual");
  console.log("require=" + require("zz-dual").which + " import()=" + ns.which);
});

Main and this PR, release builds of main 89452d2 and of this branch at 6577089:

Case main this PR
mock.module, jest.mock, jest.doMock, vi.mock, vi.doMock of zz-dual require=real cjs import()=factory require=factory import()=factory
the same for a subpath (zz-sub/feature), nested conditions, #dep of imports, ./lib beside lib.mjs and lib.cjs, a folder with index.mjs and index.cjs real for require() factory for both
factory calls, and require(pkg).fn === (await import(pkg)).fn 1, false 1, true
openat of zz-dual/cjs.cjs while the mock stands 1 0
jest.requireActual("zz-dual") {"default":{"which":"real esm"},"which":"real esm"} {"which":"real cjs"}
vi.importActual("zz-dual") the ES module the ES module
mock.module("uuid", f) (uuid 9.0.1), then require("uuid").v4() a random id the mock
a package with one file factory for both factory for both
a package that only import resolves require() throws Cannot find module the same

References, run here. Jest 30.5.2: jest.mock("zz-dual", factory) gives require("zz-dual") the factory's object, also in a CommonJS helper. jest.requireActual("zz-dual") is {"which":"real cjs"}. Jest resolves the name with the require conditions. Vitest 5.1.0: vi.mock never reaches require(), also not for a package with one file. Bun already gives require() the vi.mock mock of a one-file package (docs/test/mocks.mdx, "Mocking a Package"), so a package with two files now gets the same.

Which names resolve to two paths. exports conditions (root, subpath, nested), imports (#name), and a name without an extension beside a .mjs and a .cjs file. A package with main and module does not: both resolve to main at run time. An onResolve plugin is asked once and is not told who asks, so its answer is the one path.

Rules of the table.

  • The own path of a mock is the path of import. A later mock with the same own path replaces it, with both entries.
  • The path of require() is a claim. The own path of another mock goes before it. So mock.module(require.resolve("pkg"), f) beside mock.module("pkg", g) still gives require() the object of f and import the object of g, in both orders. When the mock of the path ends, the claim answers again.
  • The ES loader has one module for a mock, under the path of import. An import of the path of require() gets that module.
  • The two files of one package get each other as they are on disk. An ES module that wraps the CommonJS file is the usual case, and importOriginal() then loads both real files.

Which path each place uses.

Place Path
addModuleMock, removeModuleMock, replaceModuleMock both
listModuleMocks (jest.resetModules, pending patches, the end of a test file, Bun.plugin.clearAll) each mock once
findLoadedModule(mock) the ES module under the import path, the module object of require.cache under the require path
appendMockedModule (evictions), setLoadedModuleAside, packagesToBringBack both
what a mock saved of a patched ES module import path
what a mock saved of a patched module object require path
the factory turns, moduleMocksBeingLoaded, settle, importOriginalModule import path
requireOriginalModule, jest.requireActual, jest.requireMock, jest.createMockFromModule require path
dependencyKeyOfModuleMock, findManualModuleMock require path for jest.mock and jest.doMock, else import path
canRequireOriginalOfModuleMock both originals
keyOfImportWhileModuleMocksRun either path of the running mock gives its original
unmock, vi.importActual, vi.importMock import path
bun test --aot (formThatMockFinds, runCompiledMock) import path, as before

The older tests with two files for each module. A shim of tempDir gave every ES module x.ts of the fixtures a twin x.mts, so ./x resolves to one file for import and to the other for require(). The suite then ran on both builds: main passes 288 of 307, this PR 316 of 325. The 9 that still fail are made by the twins, not by the mocks: 6 count the loads of mod.ts and mod.mts as one module, 2 compare require("./mod") with await import("./mod"), which are two real files, and 1 has a plugin filter for dependency.ts only.

Real packages. uuid 9.0.1 (an ES wrapper around the CommonJS file), nanoid 3.3.7, date-fns 3.6.0 and axios 1.7.7 each resolve to two files. On main mock.module(pkg, factory) leaves require(pkg) real for all four. With this PR require() and import() get one object from one factory call, jest.unmock() brings both back, and for uuid importOriginal(), jest.requireActual() in the factory and jest.doMock("uuid") without a factory work for both.

Measurements. Release builds of main 89452d2 and of this branch at 6577089, from one build directory, on Linux x64. Instructions are user instructions from perf_event_open, as a slope over two loop lengths, the lowest of 5 runs. perf, strace and bloaty are not installed here.

  • Binary: .text +8,576 bytes (size -A: 112,978,537 to 112,987,113). .rodata, .data and .bss have the same size. HOST_EXPORT markers: one replaced, 130 on both.
  • One mock call (slope over 20,000 and 40,000 calls):
Call Name main this PR
mock.module ./rel.js 11,053 18,306 (+7,253)
mock.module a package with one file 19,788 36,130 (+16,342)
mock.module a package with two files 16,253 30,726 (+14,473)
jest.doMock ./rel.js 11,054 18,307 (+7,253)
jest.doMock a package with one file 19,788 36,127 (+16,339)
jest.doMock a package with two files 16,249 30,724 (+14,475)
vi.doMock ./rel.js 15,382 22,434 (+7,052)
vi.doMock a package with one file 28,428 44,697 (+16,269)
vi.doMock a package with two files 24,802 45,090 (+20,288)
jest.requireActual ./rel.js 17,226 23,305 (+6,079)
jest.requireActual a package with one file 26,353 41,427 (+15,074)
jest.requireActual a package with two files 22,791 34,485 (+11,694)
jest.unmock ./rel.js 7,484 7,571 (+87)
jest.unmock a package with one file 16,057 16,144 (+87)
jest.unmock a package with two files 12,519 12,607 (+88)
  • require() and import() of a module that has no mock (slope over 300,000 and 600,000 calls, and over 1,000 and 2,000 new .cjs files):
Work No mock ever A mock of another module A mock with two paths After the last mock ended
require() of a loaded module 16,150 to 16,145 (-5) 16,876 to 16,872 (-4) 16,903 to 16,993 (+90) 16,886 to 16,891 (+5)
await import() of a loaded module 25,012 to 25,009 (-3) 25,902 to 25,907 (+5) 25,933 to 26,068 (+135) 25,910 to 25,916 (+6)
require() of a new .cjs file 140,398 to 140,370 (-28) 144,717 to 144,709 (-8) 144,747 to 144,827 (+80) 144,715 to 144,734 (+19)
  • A failing require() while a mock exists: 8.08 and 8.10 syscalls per call on main, 8.10 and 8.09 here (ptrace count over 1,000 calls, with a mock of a one-file name and of a two-file name). require() resolves once, as before.
  • Runs of an onResolve callback for one call: mock.module 1, jest.requireActual 2, jest.doMock() without a factory 2, jest.unmock 1, on both builds.
  • Memory: a table entry is 16 bytes as before, and a mock with two paths has two. A ModuleMock cell is 272 bytes, was 256 (heap snapshot): one more pointer, which takes the next size class. The new counter of BunPlugin::OnLoad lies in padding, between a pointer and two bool fields. 100 mocks of names with two paths are 100 ModuleMock cells. After jest.resetModules() there are 200, as on main for a one-file package that both require() and import() loaded.
  • ->specifier reads in BunPlugin.cpp: 112 on main 89452d2, 0 here. Each is now importKey, keyForRequire() or a helper that names both. Callers of the resolver entry with a bool: 3 on main, 0 here.

Not changed.

  • bun test --aot: a require("pkg") whose name the build resolved still loads the real file, as docs/compiler/debugging.mdx now says under "Module mocks". A require(name) that is resolved at run time gets the mock. The 18 new tests use the first form, so they are in test/aot/runner-suites.known.txt, and 16 of them in runner-suites.runs.txt.
  • A package that only import resolves: require() of it still throws. Node does not retry there either.
  • A mock saves what it patched in place once for the ES module and once for the module object. So an ES module that was loaded by the path of require() before the mock is not patched. An import of that path after the mock gets the mock.
  • A .node addon and the vi.mock package rule at the Module._resolveFilename exit are as on main.

Open pull requests that touch the same functions. #48375 turns the table entry into { provider, mock }. This PR keeps the entry of main and hides the table behind methods, with the names of #48375 where they agree, so either can land first. #47819, #48178, #48179 and #48305 change the require() answer of a mock, the load in flight and the builtin originals. None of them changes which path a mock answers for.

…its name to

A package whose `exports` names one file for `import` and another for
`require` resolves to two paths. A module mock was registered under the
first one only, so require() of the mocked name loaded the real file.

The mock now has both paths and the table of the mocks holds it under
each. One resolver entry returns both, and each mock API takes the one
that its kind loads: jest.requireActual() the file of require(),
vi.importActual() the file of an import.
…import = require() and a library beside a wrapper package
@robobun

robobun commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:58 PM PT - Nov 10th, 2026

✅ @robobun, your commit 657708987f3fab3c7d7d952806796725424fc21f passed in Build #139841! 🎉


🧪   To try this PR locally:

bunx bun-pr 48467

That installs a local version of the PR into your bun-48467 executable, so you can run:

bun-48467 --bun

@robobun

robobun commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on main 89452d2 (release build) with a package whose exports has one file for import and one for require. mock.module("zz-dual", factory), then require("zz-dual") and await import("zz-dual"), prints require=real cjs import()=factory. This branch prints require=factory import()=factory. The steps are in the Notes of #48467.

The PR stays a draft. A review of the branch found that it keeps an ended mock in require.cache when a suite mocks a package by name and by require.resolve(name) and the CommonJS file was loaded first. The rework changes the table rule (the later mock of a path takes it), registers no require path under bun test --aot, and leaves vi.mock at one path.

main's #48120 rewrote large parts of src/jsc/bindings/BunPlugin.cpp. The code that it added reads a mock's own path as `importKey`, lists the mocks with listModuleMocks() where it went through the table, and ends a mock with unmockModule(mock, ...), which takes the path from the mock.
test/aot/runner-suites.known.txt and runner-suites.runs.txt keep the lines of the new tests.
main's #48436 renamed fields and functions of src/jsc/bindings/BunPlugin.cpp on lines that read a mock's own path. Those lines take main's names and read `importKey`.
docs/compiler/compatibility.mdx no longer has a section for `bun test --aot`. The sentence about a name with two files is now the third difference under "Module mocks" in docs/compiler/debugging.mdx.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant