Skip to content

feat(ext/node): implement node:test mock.module#35329

Merged
bartlomieju merged 4 commits into
mainfrom
feat/node-test-mock-module
Jun 20, 2026
Merged

feat(ext/node): implement node:test mock.module#35329
bartlomieju merged 4 commits into
mainfrom
feat/node-test-mock-module

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jun 18, 2026

Copy link
Copy Markdown
Member

Implements mock.module(specifier, options) on the node:test mock object,
matching Node 26.3.0. It replaces the module resolved from specifier with a
synthetic module exposing defaultExport and namedExports for the duration
of the test, working for both ESM import() and CJS require(), and returns a
MockModuleContext with .restore() that also participates in
mock.reset() / mock.restoreAll(). The legacy exports option is also
supported: its default key becomes the default export and its remaining own
enumerable keys become named exports; it cannot be combined with namedExports
or defaultExport.

The feature is built on top of module.registerHooks() (the module
customization hooks landed in #35026 / #35027) rather than a parallel
mechanism. The first mock.module() call lazily registers a single
resolve/load hook pair that intercepts both module systems:

  • The load hook serves synthetic source for any resolved url that maps to an
    active mock. For a CommonJS module the named exports are applied onto the
    default export (throwing the same "Cannot create mock..." error Node throws
    when they cannot be); for an ESM module the default and named exports are
    exposed independently.
  • The resolve hook versions the resolved url for ESM importers so that the
    default cache: false yields a fresh module namespace on every import while
    cache: true reuses a stable url. The CJS side is handled by evicting the
    require cache on setup and restoring the original cached instance on
    restore().

The live namedExports / defaultExport values cross into the synthetic
module through a registry published on globalThis under a well-known symbol;
the generated source reads them back by url key, so functions and objects pass
through unchanged.

The whole subsystem is primordial-only and lazily initialized: importing
node:test without mocking modules registers no hooks and adds no startup
cost, in line with the zero-cost-when-unused direction for the mock subsystem.

Spec tests under tests/specs/node/node_test_mock_module cover named + default
exports, the ESM importer, the CJS require importer, a .js module (ESM format
detection), non-identifier export names, the exports option and its
validations, the cache option, restore, and the interaction with
mock.reset() / mock.restoreAll().

The upstream Node conformance file parallel/test-runner-module-mocking.js is
intentionally not enabled in tests/node_compat/config.jsonc yet. The
node_compat runner judges a test file by its overall exit code, and that file
cannot pass as a whole in Deno for two independent reasons:

  • It exercises features deferred from this PR: JSON mocking with
    with { type: "json" } import attributes (fix(ext/node): import attributes and custom module types in registerHooks ESM hooks #35028), automatic per-test mock
    restore (Deno's node:test has no per-test mock tracker yet), and live
    getter evaluation of defaultExport / exports.default (this PR snapshots
    export values eagerly).
  • Some subtests spawn process.execPath with the Node-only flag
    --experimental-test-module-mocks, which Deno does not accept, so they fail
    regardless of feature completeness.

The implemented behavior is gated by the spec tests above; the conformance file
can be enabled once the deferred lifecycle / import-attribute work lands.

Deferred, and called out so they are not mistaken for regressions:

  • JSON mocking and with { type: "json" } import attributes depend on the
    in-flight import-attributes / custom-module-type work (fix(ext/node): import attributes and custom module types in registerHooks ESM hooks #35028).
  • Node restores each test context's mocks automatically when the test
    finishes; Deno's node:test has no per-test mock tracker yet, so the
    conformance cases that rely on automatic per-test restore still need that
    separate lifecycle feature. Explicit restore() / reset() /
    restoreAll() all work.
  • defaultExport / exports.default getters are read once at mock setup
    rather than per module-system evaluation; live getter semantics need the
    registry to read through to the original options object.

Adds `mock.module(specifier, options)` to the node:test `mock` object,
matching Node 26.3.0. It replaces the module resolved from `specifier`
with a synthetic module exposing `defaultExport` and `namedExports` for
the duration of the test, and returns a `MockModuleContext` with
`.restore()`.

The implementation is built on top of `module.registerHooks()` rather
than a parallel mechanism. On the first `mock.module()` call it lazily
registers a single resolve/load hook pair that intercepts both ESM
`import()` and CJS `require()`:

- The load hook serves synthetic source for any resolved url that maps
  to an active mock. CommonJS importers get `module.exports` with named
  exports applied onto the default export; ESM importers get an
  independent default and named exports.
- The resolve hook versions the resolved url for ESM importers so that
  the `cache: false` default yields a fresh module on every import,
  while `cache: true` reuses a stable url. CJS cache behavior is handled
  by evicting (and on restore, restoring) the require cache entry.

The live `namedExports` / `defaultExport` values cross into the
synthetic module via a registry published on `globalThis` under a
well-known symbol; the generated source reads them back by url key.

Everything is primordial-only and lazily loaded, so importing
`node:test` without mocking modules registers no hooks and adds no
startup cost.

Spec tests under tests/specs/node/node_test_mock_module cover named +
default exports, ESM and CJS importers, the cache option, restore(), and
interaction with mock.reset()/restoreAll().

JSON mocking and `with { type: "json" }` import-attribute handling are
deferred to the in-flight import-attributes work (#35028).
- detectFormat used an undefined getUrlHelpers(), which threw and was
  swallowed by the catch, silently detecting every .js file URL as
  commonjs. Use getNodeUrl().fileURLToPath() instead, and treat
  http(s) urls as ESM.
- ESM source generation emitted `export let <name>` with the raw export
  name, producing a syntax error for non-identifier names. Use a safe
  local identifier plus a string export name.
- Add a js_format spec test covering .js ESM detection and a
  non-identifier ("foo-bar") named export.
@bartlomieju
bartlomieju marked this pull request as ready for review June 20, 2026 09:44
Adds the legacy `exports` option to mock.module(): its `default` key
becomes the default export and its remaining own enumerable keys become
named exports. It cannot be combined with `namedExports` or
`defaultExport` (ERR_INVALID_ARG_VALUE), and must be an object
(ERR_INVALID_ARG_TYPE), matching Node.

Spec tests cover the three validations and the exports semantics for ESM
and CJS modules observed from both module systems.
@bartlomieju
bartlomieju merged commit e38a472 into main Jun 20, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the feat/node-test-mock-module branch June 20, 2026 19:23
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