Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/mock/src/reexport/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './inner';
3 changes: 3 additions & 0 deletions e2e/mock/src/reexport/inner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function captureException(err: string): string {
return `REAL:${err}`;
}
7 changes: 7 additions & 0 deletions e2e/mock/src/reexport/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@rstest/tests-mock-reexport",
"version": "1.0.0",
"private": true,
"sideEffects": false,
"type": "module"
}
10 changes: 10 additions & 0 deletions e2e/mock/src/reexportCaller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Calls a re-exported namespace export from another module (like `@sentry/react`
// re-exporting `@sentry/browser`'s `captureException`). Under the bug's trigger
// conditions this access is inlined straight to the origin module, which is what
// makes a runtime `rs.spyOn` on the namespace object no-op. `rs.mock(..., { spy })`
// still intercepts it because it replaces the module factory at build time.
import * as NS from './reexport/index';

export function doCapture(): string {
return NS.captureException('boom');
}
30 changes: 30 additions & 0 deletions e2e/mock/tests/mockSpyReexport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, rs, test } from '@rstest/core';
import * as NS from '../src/reexport/index';
import { doCapture } from '../src/reexportCaller';

// Regression for https://github.com/web-infra-dev/rstest/issues/1492
// `rs.spyOn(NS, 'captureException')` silently no-ops on a re-exported namespace
// export (the `sideEffects` optimization inlines the caller's access straight to
// the origin module, bypassing the namespace object the spy patches). The
// documented alternative — `rs.mock('pkg', { spy: true })` — replaces the module
// factory at build time and intercepts the same calls, including from another
// module. This test locks that behavior in.
rs.mock('../src/reexport/index', { spy: true });

describe('rs.mock spy on re-exported namespace export (#1492)', () => {
test('exports are wrapped in spies', () => {
expect(rs.isMockFunction(NS.captureException)).toBe(true);
});

test('intercepts a call made from another module', () => {
doCapture();
expect(NS.captureException).toHaveBeenCalledWith('boom');
});

test('preserves the original implementation and can override it', () => {
expect(NS.captureException('x')).toBe('REAL:x');

rs.mocked(NS.captureException).mockReturnValue('MOCK');
expect(NS.captureException('y')).toBe('MOCK');
});
});
83 changes: 83 additions & 0 deletions e2e/spy/spyOnModuleNamespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it, rstest } from '@rstest/core';

// Follow-up for https://github.com/web-infra-dev/rstest/issues/1492
// Spying a non-configurable export of an ES module namespace (a native ES
// module, e.g. an externalized dependency in the `node` test environment) can't
// redefine the binding. Instead of a bare "Cannot redefine property", rstest
// throws an actionable error pointing at `rs.mock(..., { spy: true })`. The
// guard is deliberately precise: it fires ONLY for a `[object Module]` namespace
// whose target export is non-configurable — ordinary non-configurable object
// properties keep their original error.
describe('spyOn on a frozen ES module namespace export', () => {
const makeModuleNamespace = <K extends string>(
name: K,
configurable: boolean,
): Record<K, () => string> => {
const ns = {} as Record<K, () => string>;
Object.defineProperty(ns, Symbol.toStringTag, { value: 'Module' });
Object.defineProperty(ns, name, {
value: () => 'real',
enumerable: true,
configurable,
});
return ns;
};

const messageOf = (fn: () => void): string => {
try {
fn();
return '<did not throw>';
} catch (error) {
return (error as Error).message;
}
};

it('throws an actionable error recommending rs.mock spy', () => {
const ns = makeModuleNamespace('captureException', false);
const message = messageOf(() => rstest.spyOn(ns, 'captureException'));

expect(message).toContain('[Rstest]');
expect(message).toContain("rs.mock('<module>', { spy: true })");
});

it('leaves ordinary non-configurable properties with their original error', () => {
const obj: Record<PropertyKey, unknown> = {};
Object.defineProperty(obj, 'method', {
value: () => 'x',
configurable: false,
});

const message = messageOf(() =>
rstest.spyOn(obj as Record<string, () => string>, 'method'),
);

expect(message).toContain('Cannot redefine property');
expect(message).not.toContain('[Rstest]');
});

it('does not fire for a module namespace with a configurable (spyable) export', () => {
const ns = makeModuleNamespace('greet', true);

const spy = rstest.spyOn(ns, 'greet');
ns.greet();
expect(spy).toHaveBeenCalled();
});

it('still spies a writable non-configurable export of a transpiled CJS (__esModule) object', () => {
// An `__esModule` interop object is an ordinary object, not a real module
// namespace; a writable (even if non-configurable) export can still be
// redefined, so the guard must NOT fire and the spy must work.
const mod = {} as { fn: () => string };
Object.defineProperty(mod, '__esModule', { value: true });
Object.defineProperty(mod, 'fn', {
value: () => 'real',
writable: true,
configurable: false,
enumerable: true,
});

const spy = rstest.spyOn(mod, 'fn').mockReturnValue('mocked');
expect(mod.fn()).toBe('mocked');
expect(spy).toHaveBeenCalled();
});
});
20 changes: 20 additions & 0 deletions packages/core/src/runtime/api/spy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,26 @@ export const initSpy = (
}
}

// A native ES module namespace is an exotic object whose exports can't be
// redefined — even when the descriptor reports `writable: true` — so
// installing a spy fails with a bare "Cannot redefine property". Detect that
// exact object (a `[object Module]`) and throw an actionable error instead.
// The check matches ONLY a real module namespace, deliberately NOT an
// `__esModule` interop object from a transpiled CommonJS module: those are
// ordinary objects whose writable exports tinyspy can still spy, so they
// must fall through untouched. Bundled deps expose *configurable* getters,
// so this never fires for them either. See
// https://github.com/web-infra-dev/rstest/issues/1492
const targetDescriptor = Object.getOwnPropertyDescriptor(obj, methodName);
if (
targetDescriptor?.configurable === false &&
(obj as Record<PropertyKey, unknown>)[Symbol.toStringTag] === 'Module'
) {
throw new Error(
`[Rstest] Cannot spy on "${String(methodName)}": it is a read-only export of a third-party or native ES module. Mock the module instead with \`rs.mock('<module>', { spy: true })\`. See https://rstest.rs/api/runtime-api/rstest/mock-functions#rsspyon`,
);
}

const accessTypeMap = {
get: 'getter',
set: 'setter',
Expand Down
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions website/docs/en/api/runtime-api/rstest/mock-functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ expect(hi.sayHi()).toBe('hello');
expect(rs.spyOn(hi, 'sayHi')).toBeCalled();
```

:::note Spying on re-exported or third-party exports

`rs.spyOn` works on an export defined in the module you import from, but not on one **re-exported** from another module (`export * from '...'`) or provided by a third-party dependency — there the spy may not take effect.

For those, mock the module with [`{ spy: true }`](/api/runtime-api/rstest/mock-modules#with-spy-true-option) instead. It spies every export while keeping its real implementation:

```ts
rs.mock('pkg', { spy: true });
```

:::

## rs.isMockFunction

- **Alias:** `rstest.isMockFunction`
Expand Down
2 changes: 1 addition & 1 deletion website/docs/en/api/runtime-api/rstest/mock-modules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ test('falls back to auto-mocking', () => {
});
```

### With `{ spy: true }` option
### With `{ spy: true }` option \{#with-spy-true-option}

If `{ spy: true }` is provided as the second parameter, the module will be auto-mocked but the original implementations will be preserved. All exports will be wrapped in spy functions that track calls while still executing the original code.

Expand Down
12 changes: 12 additions & 0 deletions website/docs/zh/api/runtime-api/rstest/mock-functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ expect(hi.sayHi()).toBe('hello');
expect(rs.spyOn(hi, 'sayHi')).toBeCalled();
```

:::note 对 re-export 或第三方模块的导出进行 spy

`rs.spyOn` 能作用于你所导入模块中**直接定义**的导出,但对从其他模块 **re-export**(`export * from '...'`)或由第三方依赖提供的导出可能不生效。

这种情况下,请改用 [`{ spy: true }`](/api/runtime-api/rstest/mock-modules#with-spy-true-option) 来 mock 该模块。它会在保留真实实现的同时,为每个导出装上 spy:

```ts
rs.mock('pkg', { spy: true });
```

:::

## rs.isMockFunction

- **别名:** `rstest.isMockFunction`
Expand Down
2 changes: 1 addition & 1 deletion website/docs/zh/api/runtime-api/rstest/mock-modules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ test('falls back to auto-mocking', () => {
});
```

### 使用 `{ spy: true }` 选项
### 使用 `{ spy: true }` 选项 \{#with-spy-true-option}

如果第二个参数提供了 `{ spy: true }`,模块将被自动 mock,但原始实现会被保留。所有导出都会被包装在 spy 函数中,这些函数会追踪调用同时仍然执行原始代码。

Expand Down
Loading