Skip to content

Commit 0c09a40

Browse files
authored
feat!: update mock implementation to support ESM runtime, introduce "vi.hoisted" (#3258)
1 parent da2f197 commit 0c09a40

39 files changed

+2430
-693
lines changed

docs/api/expect.md

+7-7
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ type Awaitable<T> = T | PromiseLike<T>
138138

139139
```ts
140140
import { Stocks } from './stocks.js'
141-
141+
142142
const stocks = new Stocks()
143143
stocks.sync('Bill')
144144
if (stocks.getInfo('Bill'))
@@ -150,7 +150,7 @@ type Awaitable<T> = T | PromiseLike<T>
150150
```ts
151151
import { expect, test } from 'vitest'
152152
import { Stocks } from './stocks.js'
153-
153+
154154
const stocks = new Stocks()
155155

156156
test('if we know Bill stock, sell apples to him', () => {
@@ -171,7 +171,7 @@ type Awaitable<T> = T | PromiseLike<T>
171171

172172
```ts
173173
import { Stocks } from './stocks.js'
174-
174+
175175
const stocks = new Stocks()
176176
stocks.sync('Bill')
177177
if (!stocks.stockFailed('Bill'))
@@ -183,7 +183,7 @@ type Awaitable<T> = T | PromiseLike<T>
183183
```ts
184184
import { expect, test } from 'vitest'
185185
import { Stocks } from './stocks.js'
186-
186+
187187
const stocks = new Stocks()
188188

189189
test('if Bill stock hasn\'t failed, sell apples to him', () => {
@@ -242,7 +242,7 @@ type Awaitable<T> = T | PromiseLike<T>
242242

243243
```ts
244244
import { expect, test } from 'vitest'
245-
245+
246246
const actual = 'stock'
247247

248248
test('stock is type of string', () => {
@@ -259,7 +259,7 @@ type Awaitable<T> = T | PromiseLike<T>
259259
```ts
260260
import { expect, test } from 'vitest'
261261
import { Stocks } from './stocks.js'
262-
262+
263263
const stocks = new Stocks()
264264

265265
test('stocks are instance of Stocks', () => {
@@ -695,7 +695,7 @@ If the value in the error message is too truncated, you can increase [chaiConfig
695695
## toMatchFileSnapshot
696696

697697
- **Type:** `<T>(filepath: string, message?: string) => Promise<void>`
698-
- **Version:** Vitest 0.30.0
698+
- **Version:** Since Vitest 0.30.0
699699

700700
Compare or update the snapshot with the content of a file explicitly specified (instead of the `.snap` file).
701701

docs/api/vi.md

+69-2
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,55 @@ import { vi } from 'vitest'
114114

115115
When using `vi.useFakeTimers`, `Date.now` calls are mocked. If you need to get real time in milliseconds, you can call this function.
116116

117+
## vi.hoisted
118+
119+
- **Type**: `<T>(factory: () => T) => T`
120+
- **Version**: Since Vitest 0.31.0
121+
122+
All static `import` statements in ES modules are hoisted to top of the file, so any code that is define before the imports will actually be executed after imports are evaluated.
123+
124+
Hovewer it can be useful to invoke some side effect like mocking dates before importing a module.
125+
126+
To bypass this limitation, you can rewrite static imports into dynamic ones like this:
127+
128+
```diff
129+
callFunctionWithSideEffect()
130+
- import { value } from './some/module.ts'
131+
+ const { value } = await import('./some/module.ts')
132+
```
133+
134+
When running `vitest`, you can do this automatically by using `vi.hoisted` method.
135+
136+
```diff
137+
- callFunctionWithSideEffect()
138+
import { value } from './some/module.ts'
139+
+ vi.hoisted(() => callFunctionWithSideEffect())
140+
```
141+
142+
This method returns the value that was returned from the factory. You can use that value in your `vi.mock` factories if you need an easy access to locally defined variables:
143+
144+
```ts
145+
import { expect, vi } from 'vitest'
146+
import { originalMethod } from './path/to/module.js'
147+
148+
const { mockedMethod } = vi.hoisted(() => {
149+
return { mockedMethod: vi.fn() }
150+
})
151+
152+
vi.mocked('./path/to/module.js', () => {
153+
return { originalMethod: mockedMethod }
154+
})
155+
156+
mockedMethod.mockReturnValue(100)
157+
expect(originalMethod()).toBe(100)
158+
```
159+
160+
117161
## vi.mock
118162

119163
- **Type**: `(path: string, factory?: () => unknown) => void`
120164

121-
Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn't matter where you call it. It will always be executed before all imports.
165+
Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn't matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can defined them inside [`vi.hoisted`](/api/vi#vi-hoisted) and reference inside `vi.mock`.
122166

123167
::: warning
124168
`vi.mock` works only for modules that were imported with the `import` keyword. It doesn't work with `require`.
@@ -151,6 +195,29 @@ import { vi } from 'vitest'
151195
This also means that you cannot use any variables inside the factory that are defined outside the factory.
152196

153197
If you need to use variables inside the factory, try [`vi.doMock`](#vi-domock). It works the same way but isn't hoisted. Beware that it only mocks subsequent imports.
198+
199+
You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`:
200+
201+
```ts
202+
import { namedExport } from './path/to/module.js'
203+
204+
const mocks = vi.hoisted(() => {
205+
return {
206+
namedExport: vi.fn(),
207+
}
208+
})
209+
210+
vi.mock('./path/to/module.js', () => {
211+
return {
212+
namedExport: mocks.namedExport,
213+
}
214+
})
215+
216+
vi.mocked(namedExport).mockReturnValue(100)
217+
218+
expect(namedExport()).toBe(100)
219+
expect(namedExport).toBe(mocks.namedExport)
220+
```
154221
:::
155222

156223
::: warning
@@ -199,7 +266,7 @@ import { vi } from 'vitest'
199266
```
200267

201268
::: warning
202-
Beware that if you don't call `vi.mock`, modules **are not** mocked automatically.
269+
Beware that if you don't call `vi.mock`, modules **are not** mocked automatically. To replicate Jest's automocking behaviour, you can call `vi.mock` for each required module inside [`setupFiles`](/config/#setupfiles).
203270
:::
204271

205272
If there is no `__mocks__` folder or a factory provided, Vitest will import the original module and auto-mock all its exports. For the rules applied, see [algorithm](/guide/mocking#automocking-algorithm).

docs/config/index.md

+15-2
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ Listen to port and serve API. When set to true, the default port is 51204
963963

964964
### browser
965965

966-
- **Type:** `{ enabled?, name?, provider?, headless?, api? }`
966+
- **Type:** `{ enabled?, name?, provider?, headless?, api?, slowHijackESM? }`
967967
- **Default:** `{ enabled: false, headless: process.env.CI, api: 63315 }`
968968
- **Version:** Since Vitest 0.29.4
969969
- **CLI:** `--browser`, `--browser=<name>`, `--browser.name=chrome --browser.headless`
@@ -1035,6 +1035,19 @@ export interface BrowserProvider {
10351035
This is an advanced API for library authors. If you just need to run tests in a browser, use the [browser](/config/#browser) option.
10361036
:::
10371037

1038+
#### browser.slowHijackESM
1039+
1040+
- **Type:** `boolean`
1041+
- **Default:** `true`
1042+
- **Version:** Since Vitest 0.31.0
1043+
1044+
When running tests in Node.js Vitest can use its own module resolution to easily mock modules with `vi.mock` syntax. However it's not so easy to replicate ES module resolution in browser, so we need to transform your source files before browser can consume it.
1045+
1046+
This option has no effect on tests running inside Node.js.
1047+
1048+
This options is enabled by default when running in the browser. If you don't rely on spying on ES modules with `vi.spyOn` and don't use `vi.mock`, you can disable this to get a slight boost to performance.
1049+
1050+
10381051
### clearMocks
10391052

10401053
- **Type:** `boolean`
@@ -1358,7 +1371,7 @@ The number of milliseconds after which a test is considered slow and reported as
13581371

13591372
- **Type:** `{ includeStack?, showDiff?, truncateThreshold? }`
13601373
- **Default:** `{ includeStack: false, showDiff: true, truncateThreshold: 40 }`
1361-
- **Version:** Vitest 0.30.0
1374+
- **Version:** Since Vitest 0.30.0
13621375

13631376
Equivalent to [Chai config](https://github.com/chaijs/chai/blob/4.x.x/lib/chai/config.js).
13641377

examples/mocks/test/hoisted.test.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expect, test, vi } from 'vitest'
2+
import { asyncSquare as importedAsyncSquare, square as importedSquare } from '../src/example'
3+
4+
const mocks = vi.hoisted(() => {
5+
return {
6+
square: vi.fn(),
7+
}
8+
})
9+
10+
const { asyncSquare } = await vi.hoisted(async () => {
11+
return {
12+
asyncSquare: vi.fn(),
13+
}
14+
})
15+
16+
vi.mock('../src/example.ts', () => {
17+
return {
18+
square: mocks.square,
19+
asyncSquare,
20+
}
21+
})
22+
23+
test('hoisted works', () => {
24+
expect(importedSquare).toBe(mocks.square)
25+
expect(importedAsyncSquare).toBe(asyncSquare)
26+
})

examples/mocks/tsconfig.json

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"compilerOptions": {
3+
"module": "esnext",
4+
"target": "esnext",
5+
"moduleResolution": "nodenext",
36
"types": ["vitest/globals"]
47
}
58
}

examples/vue/test/__snapshots__/basic.test.ts.snap

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Vitest Snapshot v1
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

33
exports[`mount component 1`] = `
44
"<div>4 x 2 = 8</div>

packages/browser/package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,21 @@
3939
"prepublishOnly": "pnpm build"
4040
},
4141
"peerDependencies": {
42-
"vitest": ">=0.29.4"
42+
"vitest": ">=0.31.0"
4343
},
4444
"dependencies": {
4545
"modern-node-polyfills": "^0.1.1",
4646
"sirv": "^2.0.2"
4747
},
4848
"devDependencies": {
49+
"@types/estree": "^1.0.1",
4950
"@types/ws": "^8.5.4",
5051
"@vitest/runner": "workspace:*",
5152
"@vitest/ui": "workspace:*",
5253
"@vitest/ws-client": "workspace:*",
54+
"estree-walker": "^3.0.3",
55+
"periscopic": "^3.1.0",
56+
"rollup": "3.20.2",
5357
"vitest": "workspace:*"
5458
}
5559
}

packages/browser/src/client/index.html

+49
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,55 @@
2424
</head>
2525
<body>
2626
<iframe id="vitest-ui" src=""></iframe>
27+
<script>
28+
const moduleCache = new Map()
29+
30+
// this method receives a module object or "import" promise that it resolves and keeps track of
31+
// and returns a hijacked module object that can be used to mock module exports
32+
function wrapModule(module) {
33+
if (module instanceof Promise) {
34+
moduleCache.set(module, { promise: module, evaluated: false })
35+
return module
36+
.then(m => m.__vi_inject__)
37+
.finally(() => moduleCache.delete(module))
38+
}
39+
return module.__vi_inject__
40+
}
41+
42+
function exportAll(exports, sourceModule) {
43+
// #1120 when a module exports itself it causes
44+
// call stack error
45+
if (exports === sourceModule)
46+
return
47+
48+
if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
49+
return
50+
51+
for (const key in sourceModule) {
52+
if (key !== 'default') {
53+
try {
54+
Object.defineProperty(exports, key, {
55+
enumerable: true,
56+
configurable: true,
57+
get: () => sourceModule[key],
58+
})
59+
}
60+
catch (_err) { }
61+
}
62+
}
63+
}
64+
65+
window.__vi_export_all__ = exportAll
66+
67+
// TODO: allow easier rewriting of import.meta.env
68+
window.__vi_import_meta__ = {
69+
env: {},
70+
url: location.href,
71+
}
72+
73+
window.__vi_module_cache__ = moduleCache
74+
window.__vi_wrap_module__ = wrapModule
75+
</script>
2776
<script type="module" src="/main.ts"></script>
2877
</body>
2978
</html>

packages/browser/src/client/main.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { setupConsoleLogSpy } from './logger'
88
import { createSafeRpc, rpc, rpcDone } from './rpc'
99
import { setupDialogsSpy } from './dialog'
1010
import { BrowserSnapshotEnvironment } from './snapshot'
11+
import { VitestBrowserClientMocker } from './mocker'
1112

1213
// @ts-expect-error mocking some node apis
1314
globalThis.process = { env: {}, argv: [], cwd: () => '/', stdout: { write: () => {} }, nextTick: cb => cb() }
@@ -72,14 +73,17 @@ ws.addEventListener('open', async () => {
7273
globalThis.__vitest_worker__ = {
7374
config,
7475
browserHashMap,
75-
moduleCache: new Map(),
76+
// @ts-expect-error untyped global for internal use
77+
moduleCache: globalThis.__vi_module_cache__,
7678
rpc: client.rpc,
7779
safeRpc,
7880
durations: {
7981
environment: 0,
8082
prepare: 0,
8183
},
8284
}
85+
// @ts-expect-error mocking vitest apis
86+
globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()
8387

8488
const paths = getQueryPaths()
8589

packages/browser/src/client/mocker.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
function throwNotImplemented(name: string) {
2+
throw new Error(`[vitest] ${name} is not implemented in browser environment yet.`)
3+
}
4+
5+
export class VitestBrowserClientMocker {
6+
public importActual() {
7+
throwNotImplemented('importActual')
8+
}
9+
10+
public importMock() {
11+
throwNotImplemented('importMock')
12+
}
13+
14+
public queueMock() {
15+
throwNotImplemented('queueMock')
16+
}
17+
18+
public queueUnmock() {
19+
throwNotImplemented('queueUnmock')
20+
}
21+
22+
public prepare() {
23+
// TODO: prepare
24+
}
25+
}

packages/browser/src/client/utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export function importId(id: string) {
22
const name = `/@id/${id}`
3-
return import(name)
3+
// @ts-expect-error mocking vitest apis
4+
return __vi_wrap_module__(import(name))
45
}

0 commit comments

Comments
 (0)