Skip to content

fix(test): make mock.restore() restore modules mocked with mock.module()#26546

Open
dylan-conway wants to merge 1 commit into
mainfrom
claude/fix-mock-restore-module-7823
Open

fix(test): make mock.restore() restore modules mocked with mock.module()#26546
dylan-conway wants to merge 1 commit into
mainfrom
claude/fix-mock-restore-module-7823

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Jan 29, 2026

Copy link
Copy Markdown
Member

Summary

mock.restore() now correctly restores modules mocked with mock.module(), fixing a long-standing issue where mock.restore() only reset function spies (spyOn) but had no effect on module mocks.

Problem

mock.restore() called JSMock__resetSpies() which only handled spyOn function spies. It never touched the virtualModules map or reversed overrideExportValue patches on ESM namespace objects. This meant module mocks persisted across tests even after calling mock.restore().

Solution

Added snapshot/restore logic to JSModuleMock:

  • Snapshot: Before patching ESM exports via overrideExportValue, save original values into a plain JSObject stored as a WriteBarrier on the existing GC-managed JSModuleMock (same pattern as spyOn's spyOriginal)
  • Restore: On mock.restore(), iterate virtualModules, restore original export values via overrideExportValue, and clear mock entries
  • Re-mock safety: Preserve true originals across re-mocks of the same specifier so restore always goes back to the real module

Key discovery

JSModuleNamespaceObject::getOwnPropertyNames() returns 0 exports for Bun's synthetic namespace objects because m_names is empty. The fix uses the mock result object's property names to determine which exports to snapshot from the namespace.

Changes

  • src/bun.js/bindings/BunPlugin.cpp — Added WriteBarrier fields to JSModuleMock, snapshot logic before ESM/CJS patching, restoreModuleMocks() method
  • src/bun.js/bindings/BunPlugin.h — Added restoreModuleMocks declaration
  • src/bun.js/bindings/JSMockFunction.cpp — Wired restoreModuleMocks into JSMock__jsRestoreAllMocks
  • test/js/bun/test/mock/mock-module-restore.test.ts — New tests

Tests

5 new tests covering:

  1. Basic ESM restore (mock → restore → originals)
  2. Re-mocking after restore
  3. Multiple re-mocks then restore (preserves true originals)
  4. Combined spyOn + mock.module restore
  5. Builtin module restore (fs/promises)

All existing mock-module tests continue to pass (9 pass, 1 todo, 0 fail).

Closes #7823

@robobun

robobun commented Jan 29, 2026

Copy link
Copy Markdown
Collaborator
Updated 12:50 AM PT - Jan 29th, 2026

@dylan-conway, your commit a33f588 has 6 failures in Build #36071 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 26546

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

bun-26546 --bun

@coderabbitai

coderabbitai Bot commented Jan 29, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Captures original module export values before mocking and restores them on mock.restore(). Adds snapshot and restore target fields to JSModuleMock, restores ESM and CJS exports via a new OnLoad::restoreModuleMocks, and invokes restoration during the global mock.restore flow. New tests validate restoration behavior.

Changes

Cohort / File(s) Summary
Module mock core
src/bun.js/bindings/BunPlugin.cpp, src/bun.js/bindings/BunPlugin.h
Adds originalExportsSnapshot and restoreTarget to JSModuleMock. Implements OnLoad::restoreModuleMocks(JSC::JSGlobalObject*) to revert ESM namespace and CJS default exports from snapshots and remove restored virtual modules. Exposes restore method in header.
Mock restore integration
src/bun.js/bindings/JSMockFunction.cpp
Calls onLoadPlugins.restoreModuleMocks(globalObject) during the restore-all path so module mocks are reverted when spies are reset.
Tests
test/js/bun/test/mock/mock-module-restore.test.ts
Adds tests verifying ESM and CJS module export restoration, re-mock sequences, spy interaction with module mocks, and builtin module restoration (e.g., fs/promises).

Suggested reviewers

  • sosukesuzuki
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main change: enabling mock.restore() to revert modules mocked with mock.module(), directly addressing the issue.
Description check ✅ Passed The description covers all required sections: problem statement, solution approach, key discovery, and testing details. It provides clear context for the changes.
Linked Issues check ✅ Passed The PR fully addresses issue #7823 by implementing snapshot/restore logic for module mocks, enabling mock.restore() to revert mocked modules to their original implementations as required.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing mock.restore() for mock.module(). The modifications to BunPlugin, JSMockFunction, and new tests are all necessary and related to the core objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • MODULE-7823: Entity not found: Issue - Could not find referenced Issue.

Comment @coderabbitai help to get the list of available commands and usage tips.

@dylan-conway dylan-conway force-pushed the claude/fix-mock-restore-module-7823 branch from ed0a065 to 350bd16 Compare January 29, 2026 07:27

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/bun.js/bindings/BunPlugin.cpp (1)

662-742: Restore misses modules cached in both ESM and CJS paths.

If the same specifier exists in both esmRegistryMap and requireMap, the first branch sets originalExportsSnapshot/restoreTarget, but the second branch still patches the other cache without snapshotting. restoreModuleMocks() then restores only one cache, leaving the other mocked after mock.restore().

Consider tracking snapshots per target (ESM vs CJS) and restoring both. Example direction:

🛠️ Suggested direction (split snapshots/targets)
-    WriteBarrier<JSObject> originalExportsSnapshot;
-    WriteBarrier<JSObject> restoreTarget;
+    WriteBarrier<JSObject> originalExportsSnapshotESM;
+    WriteBarrier<JSObject> restoreTargetESM;
+    WriteBarrier<JSObject> originalExportsSnapshotCJS;
+    WriteBarrier<JSObject> restoreTargetCJS;
-                        if (!mock->originalExportsSnapshot) {
-                            mock->restoreTarget.set(vm, mock, moduleNamespaceObject);
+                        if (!mock->originalExportsSnapshotESM) {
+                            mock->restoreTargetESM.set(vm, mock, moduleNamespaceObject);
...
-                            mock->originalExportsSnapshot.set(vm, mock, snapshot);
+                            mock->originalExportsSnapshotESM.set(vm, mock, snapshot);
                         }
-            if (!mock->originalExportsSnapshot) {
+            if (!mock->originalExportsSnapshotCJS) {
...
-                    mock->originalExportsSnapshot.set(vm, mock, snapshot);
-                    mock->restoreTarget.set(vm, mock, moduleObject);
+                    mock->originalExportsSnapshotCJS.set(vm, mock, snapshot);
+                    mock->restoreTargetCJS.set(vm, mock, moduleObject);
             }

Update the re-mock carry-over, visitChildren, and restoreModuleMocks to use both snapshots.

🤖 Fix all issues with AI agents
In `@test/js/bun/test/mock/mock-module-restore.test.ts`:
- Around line 87-100: The test uses dynamic import for "node:fs/promises" (lines
using await import) even though we're only verifying mock.module and
mock.restore behavior; change to a module-scope import to capture the original
export before mocking (e.g., import { readFile as origReadFile } from
"node:fs/promises" at top), then call mock.module("fs/promises", ...) and import
the mocked export using a normal module import or by referencing the mocked
function, and finally call mock.restore() and assert that the restored export
equals origReadFile; update references to origReadFile, mock.module, and
mock.restore accordingly so no dynamic import is used in the test.

Comment on lines +87 to +100
test("mock.restore() restores builtin modules", async () => {
const origReadFile = (await import("node:fs/promises")).readFile;

mock.module("fs/promises", () => ({
readFile: () => Promise.resolve("mocked-content"),
}));

const { readFile } = await import("node:fs/promises");
expect(await readFile("anything")).toBe("mocked-content");

mock.restore();

const { readFile: restored } = await import("node:fs/promises");
expect(restored).toBe(origReadFile);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid dynamic import in this test (not testing import behavior).

Line 88+ uses dynamic import for node:fs/promises, but the test is validating mock.restore() behavior rather than dynamic import semantics. Prefer a module-scope import and capture the original export before mocking.

💡 Suggested change
-import * as spyFixture from "./spymodule-fixture";
+import * as spyFixture from "./spymodule-fixture";
+import * as fsPromises from "node:fs/promises";
...
   test("mock.restore() restores builtin modules", async () => {
-    const origReadFile = (await import("node:fs/promises")).readFile;
+    const origReadFile = fsPromises.readFile;
...
-    const { readFile } = await import("node:fs/promises");
-    expect(await readFile("anything")).toBe("mocked-content");
+    expect(await fsPromises.readFile("anything")).toBe("mocked-content");
...
-    const { readFile: restored } = await import("node:fs/promises");
-    expect(restored).toBe(origReadFile);
+    expect(fsPromises.readFile).toBe(origReadFile);
   });

As per coding guidelines: Only use dynamic import or require when the test is specifically testing dynamic import or require behavior. Otherwise, always use module-scope import statements.

🤖 Prompt for AI Agents
In `@test/js/bun/test/mock/mock-module-restore.test.ts` around lines 87 - 100, The
test uses dynamic import for "node:fs/promises" (lines using await import) even
though we're only verifying mock.module and mock.restore behavior; change to a
module-scope import to capture the original export before mocking (e.g., import
{ readFile as origReadFile } from "node:fs/promises" at top), then call
mock.module("fs/promises", ...) and import the mocked export using a normal
module import or by referencing the mocked function, and finally call
mock.restore() and assert that the restored export equals origReadFile; update
references to origReadFile, mock.module, and mock.restore accordingly so no
dynamic import is used in the test.

Previously, mock.restore() only reset function spies (spyOn) but had no
effect on modules mocked with mock.module(). This was because
mock.restore() only called JSMock__resetSpies(), which never touched
the virtualModules map or reversed overrideExportValue patches on ESM
namespace objects.

The fix adds snapshot/restore logic to JSModuleMock:
- Before patching ESM exports, snapshot original values into a plain
  JSObject using WriteBarrier on the existing GC-managed JSModuleMock
- On mock.restore(), iterate virtualModules, restore original export
  values via overrideExportValue, and clear the mock entries
- Preserve true originals across re-mocks of the same specifier

Key discovery: JSModuleNamespaceObject::getOwnPropertyNames() returns 0
exports for Bun's synthetic namespace objects (empty m_names). The fix
uses the mock result object's property names to determine which exports
to snapshot from the namespace.

Closes #7823
@dylan-conway dylan-conway force-pushed the claude/fix-mock-restore-module-7823 branch from 350bd16 to a33f588 Compare January 29, 2026 07:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/bun.js/bindings/BunPlugin.cpp`:
- Around line 786-790: Remove the stray blank line inside the null-check if
block for moduleMock: in the code that casts with jsDynamicCast<JSModuleMock*>
and checks if (!moduleMock) { ... }, delete the empty line so the continue
statement immediately follows the opening brace (i.e., change the if
(!moduleMock) block to contain only the continue statement), keeping the
existing check and behavior intact.

Comment on lines +786 to +790
auto* moduleMock = jsDynamicCast<JSModuleMock*>(entry.value.get());
if (!moduleMock) {

continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor: Remove empty line inside if block.

Line 788 appears to have an empty line inside the if block before continue. This looks like a debug artifact.

🧹 Cleanup suggestion
         auto* moduleMock = jsDynamicCast<JSModuleMock*>(entry.value.get());
         if (!moduleMock) {
-    
             continue;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
auto* moduleMock = jsDynamicCast<JSModuleMock*>(entry.value.get());
if (!moduleMock) {
continue;
}
auto* moduleMock = jsDynamicCast<JSModuleMock*>(entry.value.get());
if (!moduleMock) {
continue;
}
🤖 Prompt for AI Agents
In `@src/bun.js/bindings/BunPlugin.cpp` around lines 786 - 790, Remove the stray
blank line inside the null-check if block for moduleMock: in the code that casts
with jsDynamicCast<JSModuleMock*> and checks if (!moduleMock) { ... }, delete
the empty line so the continue statement immediately follows the opening brace
(i.e., change the if (!moduleMock) block to contain only the continue
statement), keeping the existing check and behavior intact.

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.

Restore mock.module using mock.restore not work as expect

2 participants