Skip to content

[Bug]: rs.mock() does not prevent evaluation of an externalized dependency (real module still runs) #1456

Description

@fi3ework

Version

System:
    OS: macOS 15.0.1
    CPU: (14) arm64 Apple M3 Max
  Binaries:
    Node: v25.9.0
  npmPackages:
    @rstest/core: 0.10.6
    @rstest/coverage-v8: 0.10.6

Details

When a dependency is externalized — the default for testEnvironment: 'node' (i.e. output.bundleDependencies left unset) — rs.mock() for that dependency does not prevent the real module from being evaluated. The mock value is eventually applied to the importer's binding, but the real module is still loaded and executed first. If the real module has import-time side effects that throw, the test run crashes during that evaluation — before the mock takes effect.

This lives in the same area as #1454 (a module escaping the mock), but the symptom is different. For a harmless externalized module the mock actually does apply — yet the real module is still evaluated. It only surfaces as a failure when the real module throws (or has other observable side effects) at import time.

It bit us migrating a suite from Vitest: a test that mocks electron-updater crashed with Cannot read properties of undefined (reading 'getVersion'), because the real electron-updater was still evaluated and its init runs require('electron').app.getVersion() — outside the Electron runtime require('electron') is a path string, so app is undefined. Under Vitest the mock fully replaces the module and the real code never runs. Bundling the module instead of externalizing it (output.bundleDependencies: true) makes the mock fully replace it under Rstest as well.

Minimal reproduction (no external repo — 3 files)

A node_modules package that throws at evaluation time, node_modules/boom-on-eval/:

package.json

{ "name": "boom-on-eval", "version": "1.0.0", "main": "index.js" }

index.js

// side effect at module-evaluation time
throw new Error('REAL MODULE WAS EVALUATED');
module.exports = { real: true };

index.test.ts

import { expect, it, rs } from '@rstest/core';
import mod from 'boom-on-eval';

rs.mock('boom-on-eval', () => ({ default: { mocked: true } }));

it('mock should prevent the real (throwing) module from being evaluated', () => {
  expect((mod as any).mocked).toBe(true);
});

rstest.config.ts

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

export default defineConfig({ testEnvironment: 'node', include: ['*.test.ts'] });

Reproduce Steps

  1. rstest run → ❌ the suite fails to load with Error: REAL MODULE WAS EVALUATED — the real, mocked module was evaluated.
  2. Change the config to output: { bundleDependencies: true } and re-run → ✅ passes; the real module is never evaluated and mod.mocked === true.

Additional observation: if index.js is harmless (e.g. just module.exports = { real: true }, no throw), step 1 passes with mod.mocked === true — i.e. the mock value is applied even when externalized. The problem is specifically that the real module is still evaluated.

Expected

With rs.mock('boom-on-eval') registered, the real boom-on-eval module should not be evaluated at all (as in Vitest), whether it is externalized or bundled.

Actual

The externalized real module is evaluated despite the mock; its import-time throw crashes the run.


Update — still reproduces on @rstest/core 0.11.0, plus a second (related) symptom

Re-verified while upgrading Midscene's own test suite from 0.10.6 to 0.11.0: the original symptom above (externalized mocked module still evaluated → electron-updater init crash) still reproduces, and output: { bundleDependencies: true } is still the workaround.

While migrating we hit a second, distinct symptom rooted in the same externalization behavior, which the repro above does not cover:

The mock does not propagate into an externalized package's internal imports. When you rs.mock('A'), but a different externalized package B imports A internally, B resolves the real A — the mock never reaches B. Unlike the first symptom (where the mock value still applies to the test file's binding and only the real module's side effects leak), here the mock does not apply to B's binding at all, so a spy on A gets 0 calls.

Minimal reproduction (no external repo — 4 files)

node_modules/env-singleton/package.json

{ "name": "env-singleton", "version": "1.0.0", "type": "module", "main": "index.js" }

node_modules/env-singleton/index.js

export const readFlag = () => 'REAL';

node_modules/consumer-pkg/package.json — a separate externalized package that imports the mocked one

{ "name": "consumer-pkg", "version": "1.0.0", "type": "module", "main": "index.js" }

node_modules/consumer-pkg/index.js

import { readFlag } from 'env-singleton';
export const useFlag = () => readFlag();

index.test.ts

import { expect, it, rs } from '@rstest/core';
import { useFlag } from 'consumer-pkg';

rs.mock('env-singleton', () => ({ readFlag: () => 'MOCKED' }));

it('mock of a dep should apply inside an externalized package that imports it', () => {
  expect(useFlag()).toBe('MOCKED'); // ❌ receives 'REAL'
});

rstest.config.ts

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

export default defineConfig({ testEnvironment: 'node', include: ['*.test.ts'] });

Expected

useFlag() returns 'MOCKED' — the rs.mock('env-singleton') should apply to consumer-pkg's internal import { readFlag } from 'env-singleton' (Vitest applies it). Setting output: { bundleDependencies: true } makes it pass under Rstest, same as the first symptom.

Actual

useFlag() returns 'REAL' — the externalized consumer-pkg resolved the real env-singleton, so the mock (and any spy on it) never took effect.

Real-world hit in Midscene: a test mocks @midscene/shared/env, but processCacheConfig (in the externalized workspace pkg @midscene/core/utils) reads the env singleton from the real @midscene/shared/env, so the spy on getEnvConfigInBoolean gets 0 calls. Same root cause (externalization), different surface from the electron-updater case — worth either widening this issue's scope or splitting it out.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions