Skip to content

Commit 5600772

Browse files
authored
fix(browser): show a helpful error when spying on an export (#8178)
1 parent 8a18c8e commit 5600772

File tree

5 files changed

+92
-9
lines changed

5 files changed

+92
-9
lines changed

docs/guide/browser/index.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,3 +584,45 @@ test('renders a message', async () => {
584584
When using Vitest Browser, it's important to note that thread blocking dialogs like `alert` or `confirm` cannot be used natively. This is because they block the web page, which means Vitest cannot continue communicating with the page, causing the execution to hang.
585585

586586
In such situations, Vitest provides default mocks with default returned values for these APIs. This ensures that if the user accidentally uses synchronous popup web APIs, the execution would not hang. However, it's still recommended for the user to mock these web APIs for better experience. Read more in [Mocking](/guide/mocking).
587+
588+
### Spying on Module Exports
589+
590+
Browser Mode uses the browser's native ESM support to serve modules. The module namespace object is sealed and can't be reconfigured, unlike in Node.js tests where Vitest can patch the Module Runner. This means you can't call `vi.spyOn` on an imported object:
591+
592+
```ts
593+
import { vi } from 'vitest'
594+
import * as module from './module.js'
595+
596+
vi.spyOn(module, 'method') // ❌ throws an error
597+
```
598+
599+
To bypass this limitation, Vitest supports `{ spy: true }` option in `vi.mock('./module.js')`. This will automatically spy on every export in the module without replacing them with fake ones.
600+
601+
```ts
602+
import { vi } from 'vitest'
603+
import * as module from './module.js'
604+
605+
vi.mock('./module.js', { spy: true })
606+
607+
vi.mocked(module.method).mockImplementation(() => {
608+
// ...
609+
})
610+
```
611+
612+
However, the only way to mock exported _variables_ is to export a method that will change the internal value:
613+
614+
::: code-group
615+
```js [module.js]
616+
export let MODE = 'test'
617+
export function changeMode(newMode) {
618+
MODE = newMode
619+
}
620+
```
621+
```js [module.test.ts]
622+
import { expect } from 'vitest'
623+
import { changeMode, MODE } from './module.js'
624+
625+
changeMode('production')
626+
expect(MODE).toBe('production')
627+
```
628+
:::

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export default antfu(
113113
'unused-imports/no-unused-imports': 'off',
114114
'ts/method-signature-style': 'off',
115115
'no-self-compare': 'off',
116+
'import/no-mutable-exports': 'off',
116117
},
117118
},
118119
{

packages/spy/src/index.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -467,14 +467,34 @@ export function spyOn<T, K extends keyof T>(
467467
state = fn.mock._state()
468468
}
469469

470-
const stub = tinyspy.internalSpyOn(obj, objMethod as any)
471-
const spy = enhanceSpy(stub) as MockInstance
470+
try {
471+
const stub = tinyspy.internalSpyOn(obj, objMethod as any)
472472

473-
if (state) {
474-
spy.mock._state(state)
473+
const spy = enhanceSpy(stub) as MockInstance
474+
475+
if (state) {
476+
spy.mock._state(state)
477+
}
478+
479+
return spy
475480
}
481+
catch (error) {
482+
if (
483+
error instanceof TypeError
484+
&& Symbol.toStringTag
485+
&& (obj as any)[Symbol.toStringTag] === 'Module'
486+
&& (error.message.includes('Cannot redefine property')
487+
|| error.message.includes('Cannot replace module namespace')
488+
|| error.message.includes('can\'t redefine non-configurable property'))
489+
) {
490+
throw new TypeError(
491+
`Cannot spy on export "${String(objMethod)}". Module namespace is not configurable in ESM. See: https://vitest.dev/guide/browser/#limitations`,
492+
{ cause: error },
493+
)
494+
}
476495

477-
return spy
496+
throw error
497+
}
478498
}
479499

480500
let callOrder = 0

test/browser/specs/runner.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Vitest } from 'vitest/node'
22
import type { JsonTestResults } from 'vitest/reporters'
3+
import { readdirSync } from 'node:fs'
34
import { readFile } from 'node:fs/promises'
45
import { beforeAll, describe, expect, onTestFailed, test } from 'vitest'
56
import { rolldownVersion } from 'vitest/node'
@@ -68,10 +69,11 @@ describe('running browser tests', async () => {
6869
expect(vitest.projects.map(p => p.browser?.vite.config.optimizeDeps.entries))
6970
.toEqual(vitest.projects.map(() => expect.arrayContaining(testFiles)))
7071

71-
// This should match the number of actual tests from browser.json
72-
// if you added new tests, these assertion will fail and you should
73-
// update the numbers
74-
expect(browserResultJson.testResults).toHaveLength(16 * instances.length)
72+
const testFilesCount = readdirSync('./test')
73+
.filter(n => n.includes('.test.'))
74+
.length + 1 // 1 is in-source-test
75+
76+
expect(browserResultJson.testResults).toHaveLength(testFilesCount * instances.length)
7577
expect(passedTests).toHaveLength(browserResultJson.testResults.length)
7678
expect(failedTests).toHaveLength(0)
7779
})

test/browser/test/mocking.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { expect, it, vi } from 'vitest'
2+
import * as module from '../src/calculator'
3+
4+
it('spying on an esm module prints an error', () => {
5+
const error: Error = (() => {
6+
try {
7+
vi.spyOn(module, 'calculator')
8+
expect.unreachable()
9+
}
10+
catch (err) {
11+
return err
12+
}
13+
})()
14+
expect(error.name).toBe('TypeError')
15+
expect(error.message).toMatchInlineSnapshot(`"Cannot spy on export "calculator". Module namespace is not configurable in ESM. See: https://vitest.dev/guide/browser/#limitations"`)
16+
17+
expect(error.cause).toBeInstanceOf(TypeError)
18+
})

0 commit comments

Comments
 (0)