Skip to content

[Feature]: Allow rs.mock() to externalize explicitly flagged modules #1728

Description

@9aoy

What problem does this feature solve?

rs.mock() and rs.mockRequire() can fully replace a module at runtime, but the original module may still be resolved and included in the test compilation. When the mocked module has a large dependency graph, Rstest spends time compiling and emitting code that the test will never execute, and the test runtime may still need to parse or register unused module factories.

This is primarily a problem in browser-like test environments such as jsdom and happy-dom, where dependencies are bundled by default. The unused graph may include UI frameworks, CSS and image imports, ESM-only packages, or modules with expensive initialization.

In the Node.js test environment, ordinary third-party JavaScript dependencies from node_modules are already externalized by default, so this option is usually redundant for them. The Node.js environment is affected only when a dependency would otherwise be bundled, for example because output.bundleDependencies is enabled, an alias resolves to workspace source, or the mocked request points to TypeScript/JavaScript source that Rstest transpiles.

For example, in one Jest-to-Rstest migration using a Node.js environment with dependency bundling enabled, two fully mocked dependencies accounted for most of a representative test bundle. Externalizing their original implementations reduced the emitted bundle from about 4.25 MB to 1.57 MB and the number of named functions from 1,450 to 329, while the set of observed executed functions remained effectively unchanged. The test's collection time also dropped substantially.

Users can achieve this today with output.externals, but the configuration is separated from the mock factory that makes the optimization safe:

export default defineConfig({
  output: {
    bundleDependencies: true,
    externals: {
      'heavy-sdk': 'commonjs heavy-sdk',
    },
  },
});

The user must keep the external request, aliases, subpaths, module format, and mock declarations synchronized manually. It is also difficult for migration tooling or an AI agent to tell whether an external exists specifically because the module is fully mocked.

This proposal allows the user to place that build contract next to the factory that motivates it.

What does the proposed API look like?

Add an external: true option to factory-based rs.mock() and rs.mockRequire() calls:

import { expect, rs, test } from '@rstest/core';
import { request } from 'heavy-sdk';

rs.mock(
  'heavy-sdk',
  {
    external: true,
  },
  () => ({
    request: rs.fn().mockResolvedValue({ ok: true }),
  }),
);

test('uses the replacement implementation', async () => {
  await expect(request()).resolves.toEqual({ ok: true });
});

Possible type shape:

interface MockExternalOptions {
  /**
   * Externalize this module request for the current Rstest compilation.
   * The mock factory intercepts it when registered; otherwise the runtime
   * loads the real external module.
   */
  external: true;
}

function mock<T = unknown>(
  moduleName: string | Promise<T>,
  options: MockExternalOptions,
  factory: () => Partial<T>,
): void;

function mockRequire<T = unknown>(
  moduleName: string,
  options: MockExternalOptions,
  factory: () => T,
): void;

This follows the existing test(name, options, fn) and describe(name, options, fn) argument order. Existing call shapes remain unchanged:

function mock<T = unknown>(
  moduleName: string | Promise<T>,
  factory: () => Partial<T>,
): void;

function mock<T = unknown>(
  moduleName: string | Promise<T>,
  options: MockExternalOptions,
  factory: () => Partial<T>,
): void;

function mock<T = unknown>(
  moduleName: string | Promise<T>,
  options?: MockModuleOptions,
): void;

The overloads are unambiguous: the existing factory form has a function as its second argument, existing auto-mock modes use { spy: true } or { mock: true }, and the new three-argument form uses the required literal discriminator { external: true } before the factory.

Type inference from a dynamic-import argument is preserved because T is inferred from the first argument and then contextually types the third-argument factory:

rs.mock(import('./user-api'), { external: true }, () => ({
  fetchUser: rs.fn(),
}));

A string module argument has the same typing behavior as today: callers can provide an explicit generic when they want the factory checked against the real module type.

rs.mockRequire() accepts only a string request today, so it cannot infer T from the module argument. Its new overload preserves the current behavior and supports an explicit generic when useful:

rs.mockRequire<typeof import('./user-api')>(
  './user-api',
  { external: true },
  () => ({
    fetchUser: rs.fn(),
  }),
);

The options object is build-time metadata and does not need to change the mock runtime signature. The Rstest transform can remove the second argument and emit the existing runtime call shape:

// Source
rs.mock('heavy-sdk', { external: true }, factory);

// Conceptual compiled form
__webpack_require__.rstest_mock(moduleId, factory, 'heavy-sdk');

The CommonJS form similarly compiles to the existing rstest_mock_require runtime call.

ESM and CommonJS targets

rs.mock() and rs.mockRequire() intentionally use different dependency categories:

rs.mock('dual-package', { external: true }, esmFactory);
rs.mockRequire('dual-package', { external: true }, cjsFactory);
  • rs.mock() resolves the target under the ESM dependency category used by import.
  • rs.mockRequire() resolves the target under the CommonJS dependency category used by require().
  • A dual package may resolve the same raw request to different files under ESM and CommonJS conditions. Marking one form must not automatically mark the other.
  • If a project uses both entry types and wants both externalized, it declares both calls explicitly.

Resolution category and external loading type are related but separate decisions. An ESM import can resolve to a CommonJS-only package, so an rs.mock() declaration may still need a commonjs ExternalModule. Conversely, native ESM output may require import or module-import loading.

The boolean flag should reuse Rstest's existing external-type selection and should not introduce another type configuration surface in the initial API. Existing explicit configuration takes precedence:

// test
rs.mock('legacy-sdk', { external: true }, factory);

// rstest.config.ts
export default defineConfig({
  output: {
    externals: {
      'legacy-sdk': 'commonjs legacy-sdk',
    },
  },
});

Using both declarations is harmless: external: true keeps the intent next to the mock, while output.externals selects the exact external request and type. A project that prefers centralized configuration can omit the inline flag because output.externals already externalizes the module.

Behavioral contract

external: true is an explicit compilation-wide externalization contract:

  • Rstest does not bundle the resolved mock target or its transitive dependency graph.
  • The option applies to every dependency of the same ESM or CommonJS category that resolves to the same target in the current compilation, not only dependencies reachable from the test file that contains the declaration.
  • The hoisted factory mock intercepts the generated external module through Rstest's existing mock runtime.
  • If a matching import or require executes without an active mock, the runtime loads the real external module. This is allowed and must not produce an Rstest-specific error.
  • importActual and requireActual load the real external module rather than a bundled implementation.
  • unmock and unmockRequire restore normal external loading for their respective categories.
  • The real module's top-level side effects occur only if the external is actually loaded at runtime.
  • External type and loading behavior should follow Rstest's existing externalization rules. Explicit output.externals mappings or externalsType configuration take precedence when a project needs a specific type such as commonjs.

This option does not declare that the original implementation is forbidden or unreachable. It only declares that the original implementation should remain outside the Rspack bundle.

If the request is already external under Rstest's normal Node.js strategy, { external: true } does not change the build output. The declaration is idempotent and requires no warning or additional output.

Request matching

The module argument should use the same resolution path as the existing rs.mock() or rs.mockRequire() transform. Rstest resolves it relative to the file containing the mock and with the applicable Rspack dependency category and resolve configuration:

// tests/a.test.ts
rs.mock('../src/a.ts', { external: true }, factory);

Another request is externalized when resolving it from its own issuer produces the same target:

// src/consumer.ts
import './a'; // externalized if this resolves to the same src/a.ts

This should account for normal Rspack behavior such as extension resolution, extensionAlias, aliases, tsconfig paths, package exports, and symlink settings. Two different raw specifiers may therefore match, while identical-looking relative specifiers from different directories may resolve to different targets and must not match.

Resolution category and conditions still matter. Resource queries or loader-qualified requests that produce distinct modules should also remain distinct.

For a relative source target, the generated external must use a runtime-safe resolved request, such as an absolute path or file URL as appropriate for the selected external type. Emitting the original relative specifier would incorrectly resolve it relative to Rstest's output directory.

Unresolved and virtual targets

Successful filesystem resolution must not be required. Factory mocks commonly represent generated modules, native modules, runtime-provided modules, or other virtual requests that do not exist on disk:

rs.mock('virtual:platform', { external: true }, () => ({ platform: 'test' }));

Matching should use two target forms:

  • Resolved identity: When resolution succeeds, compare the resolved module identity under the same dependency category and resolve conditions.
  • Unresolved identity: When resolution fails or produces an ignored/virtual result, compare a stable request identity. Bare and scheme-based requests use the normalized specifier. Relative requests are normalized against each issuer's directory so two unrelated ./a requests do not collide.

For unresolved relative requests, Rstest should not guess extension equivalence. ./a and ./a.ts match automatically only when the resolver can prove that they represent the same target. Virtual aliases that intentionally use several specifiers may require separate declarations unless the resolver provides a shared synthetic identity.

An unresolved target can still become an ExternalModule. If the mock is installed, its factory intercepts the module and no filesystem module is required. If the mock is absent or removed, normal external loading runs and the runtime may throw its native module-not-found error.

Intended use cases

Use external: true when:

  • A heavy dependency is normally bundled by the selected test environment and replaced by a factory mock.
  • The project uses jsdom, happy-dom, or another browser-like environment that bundles dependencies by default.
  • The user accepts compilation-wide externalization of the request.
  • Any code path that executes without the mock can load the real package from the runtime environment.
  • A workspace dependency points to TypeScript source, but all exercised paths are guaranteed to install the mock before loading it.
  • The project uses output.bundleDependencies: true and needs a small number of mock-driven exceptions.

It is generally unnecessary for an ordinary third-party dependency in the Node.js environment when Rstest already externalizes that dependency by default.

Limitations and failure modes

The declaration is a user-owned build contract. Rstest does not need to prove that every runtime path is mocked.

  • If the factory is not registered before an import executes, the runtime loads the external module.
  • If that module is unavailable or incompatible with the runtime loader, the native resolution or module-format error is surfaced.
  • Externalizing unbuilt TypeScript workspace source does not make it directly loadable by Node.js. It works only while the mock intercepts every exercised path, or when a separately built runtime entry exists.
  • ESM-only, CommonJS-only, CSS, asset, native, and browser-specific modules retain their normal external-loading constraints.
  • A misspelled request may be hidden while the factory intercepts it and fail only when a path attempts real external loading.
  • Because the effect is compilation-wide, one declaration may change how the same resolved target behaves in other test entries, even when they use a different raw specifier.

The initial API should be limited to hoisted rs.mock() and rs.mockRequire() calls with a statically analyzable module request, a synchronous explicit factory, and a literal { external: true } options object. Auto-mocks and { spy: true } need the real export shape and should not be included in the initial scope. The non-hoisted rs.doMock() and rs.doMockRequire() variants can be considered separately; their conditional runtime timing does not change the compilation-wide nature of externalization.

Why this is compilation-wide

A normal import dependency records its immediate issuer, not which upstream test entry has registered a mock:

a.test.ts -- rs.mock('foo', { external: true }, factory) --+
                                                         +--> shared.ts --> import 'foo'
b.test.ts -----------------------------------------------+

When Rspack factorizes shared.ts -> foo, the immediate issuer is shared.ts. Because shared.ts and its dependency edge are normally shared in the ModuleGraph, Rstest cannot reliably decide whether that ordinary import belongs to the mocked entry, the unmocked entry, or both.

The proposed API avoids unreliable dependency-provenance inference. Once the user declares { external: true }, every dependency that resolves to the same target, or matches the same unresolved virtual identity, is externalized across the compilation. The mocked entry intercepts it; an unmocked entry loads the real external module.

Why externalization is not automatic

Rstest should not automatically externalize every module passed to a factory-based rs.mock() or rs.mockRequire() because externalization and mocking are decided in different phases.

Externalization is a build-time decision. Rspack must decide whether to create a normal bundled module or an ExternalModule during dependency factorization, before it builds the target module and its transitive graph. Avoiding the original build cost requires making this decision early.

Mock activation is a runtime behavior. The compiled test registers its factory on a particular webpack runtime when the test or setup entry executes. Whether that factory is active for a later import depends on runtime facts such as:

  • Which test and setup entries execute in that worker or browser page.
  • Their execution order and isolation mode.
  • Whether the code uses importActual, requireActual, unmock, or a conditional doMock path.
  • Whether another entry sharing the same build needs the real implementation.

The compiler can recognize the mock syntax, but syntax alone does not prove the runtime invariant that every matching import or require will be intercepted. An ordinary dependency edge contains its immediate issuer, not the runtime mock registry that will exist when the edge executes. Shared modules also merge paths from several entries into one ModuleGraph node.

By the time Rstest runs the tests and observes which factories were actually installed, factorization and compilation have already finished. That runtime information cannot retroactively remove the original module graph from the current build. Bundle-coverage or runtime tracing can recommend an optimization for a later run, but cannot safely change the build that produced the observed runtime.

A separate static pre-analysis could approximate runtime behavior, but it would need to reproduce module resolution, entry reachability, setup ordering, and mock state transitions before the real build. It would duplicate significant compiler work and still could not determine dynamic behavior accurately in the general case.

Externalization also changes the fallback path: if no mock is active, Rstest loads the real module directly from the runtime environment instead of executing its bundled form. This may expose ESM/CommonJS, TypeScript, CSS, asset, or native resolution differences that are invisible from the mock declaration alone.

{ external: true } bridges this build/runtime boundary by supplying the missing invariant explicitly. It is a reviewable, compilation-wide contract that tells the compiler it may externalize the target before runtime behavior is known. Profiling and bundle-coverage tooling may recommend the flag, but Rstest should not apply it implicitly.

Discovering the declaration before factorization

Rspack must know that a request is external before it factorizes ordinary dependencies. Discovering { external: true } as a side effect of the normal parser is potentially order-dependent because other entries and transitive modules may already be factorizing.

Rstest should collect these declarations before the main module-graph build, resolve each target relative to its declaring file when possible, and otherwise retain its normalized unresolved identity. The resulting keys are provided to the Rstest Rspack plugin or externals pipeline. For each ordinary dependency, the externalizer can use Rspack's resolver from that dependency's own context and compare either the resolved identity or the scoped unresolved fallback. Possible discovery approaches include a lightweight pre-scan of known test and setup files or an explicit pre-compilation analysis phase.

For an initial implementation, { external: true } may need to be restricted to test entry files and configured setup files that Rstest can scan before compilation. A declaration in an arbitrary imported helper should produce an actionable error rather than silently losing the optimization.

Watch mode must recompute the declaration set when test or setup files add, remove, or change { external: true }, then invalidate affected external and normal modules consistently.

Diagnostics and debugging

Debug output should make the global effect visible. For each declaration, Rstest should report:

  • The request that was externalized.
  • The test or setup file that declared it.
  • The chosen external type when available.
  • Requests that did not match any module dependency, when this can be determined without an additional full build.

Rstest should not warn merely because another entry loads the real external module; that is valid behavior under this proposal.

Alternatives considered

Existing output.externals configuration

import { defineConfig } from '@rstest/core';

export default defineConfig({
  output: {
    bundleDependencies: true,
    externals: {
      'heavy-sdk': 'commonjs heavy-sdk',
    },
  },
});

This already provides the proposed build behavior and remains the best option when external type, aliases, regular expressions, or centralized policy need explicit control.

The inline option is primarily an ergonomics and tooling improvement:

  • It colocates the mock factory and its externalization contract.
  • Removing the mock makes the associated build decision easier to discover and review.
  • Migration tools and AI agents can understand why the request is externalized without correlating separate configuration files.
  • Rstest can provide mock-specific diagnostics.

Projects that prefer centralized build configuration do not need the new API.

original: false

rs.mock('heavy-sdk', { original: false }, factory);

This could express a stronger contract: the factory is the only permitted implementation, and any attempt to access the original module through importActual, unmock, or an unmocked entry is an error.

That is not the behavior proposed for the initial version. Implementing it safely in a multi-entry compilation requires separate compilation groups or an entry-sensitive Rspack dependency graph. Translating it to a normal external would be misleading because an external explicitly allows the original module to be loaded at runtime.

original: false can remain a possible future API if Rstest later needs strict replacement-only semantics in addition to externalization.

bundle: 'external'

rs.mock('heavy-sdk', { bundle: 'external' }, factory);

This is semantically accurate but more verbose and exposes a policy string where the initial proposal has only one supported opt-in state. external: true maps more directly to the existing output.externals terminology.

mock: { externalize: [...] }

A project-level mock configuration would be another spelling of output.externals while still separating the decision from the factory. It provides little additional value over the existing configuration.

Automatic inference

Automatic inference is intentionally excluded for the reasons described in Why externalization is not automatic. The explicit flag keeps the compilation-wide behavior deterministic and reviewable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions