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
rstest run → ❌ the suite fails to load with Error: REAL MODULE WAS EVALUATED — the real, mocked module was evaluated.
- 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.
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.6Details
When a dependency is externalized — the default for
testEnvironment: 'node'(i.e.output.bundleDependenciesleft 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-updatercrashed withCannot read properties of undefined (reading 'getVersion'), because the realelectron-updaterwas still evaluated and its init runsrequire('electron').app.getVersion()— outside the Electron runtimerequire('electron')is a path string, soappisundefined. 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_modulespackage that throws at evaluation time,node_modules/boom-on-eval/:package.json{ "name": "boom-on-eval", "version": "1.0.0", "main": "index.js" }index.jsindex.test.tsrstest.config.tsReproduce Steps
rstest run→ ❌ the suite fails to load withError: REAL MODULE WAS EVALUATED— the real, mocked module was evaluated.output: { bundleDependencies: true }and re-run → ✅ passes; the real module is never evaluated andmod.mocked === true.Additional observation: if
index.jsis harmless (e.g. justmodule.exports = { real: true }, nothrow), step 1 passes withmod.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 realboom-on-evalmodule 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
throwcrashes the run.Update — still reproduces on
@rstest/core0.11.0, plus a second (related) symptomRe-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-updaterinit crash) still reproduces, andoutput: { 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 packageBimportsAinternally,Bresolves the realA— the mock never reachesB. 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 toB's binding at all, so a spy onAgets 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.jsnode_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.jsindex.test.tsrstest.config.tsExpected
useFlag()returns'MOCKED'— thers.mock('env-singleton')should apply toconsumer-pkg's internalimport { readFlag } from 'env-singleton'(Vitest applies it). Settingoutput: { bundleDependencies: true }makes it pass under Rstest, same as the first symptom.Actual
useFlag()returns'REAL'— the externalizedconsumer-pkgresolved the realenv-singleton, so the mock (and any spy on it) never took effect.Real-world hit in Midscene: a test mocks
@midscene/shared/env, butprocessCacheConfig(in the externalized workspace pkg@midscene/core/utils) reads the env singleton from the real@midscene/shared/env, so the spy ongetEnvConfigInBooleangets 0 calls. Same root cause (externalization), different surface from theelectron-updatercase — worth either widening this issue's scope or splitting it out.