Skip to content

Commit

Permalink
feat!: update mock implementation to support ESM runtime, introduce "…
Browse files Browse the repository at this point in the history
…vi.hoisted" (#3258)
  • Loading branch information
sheremet-va committed Apr 27, 2023
1 parent da2f197 commit 0c09a40
Show file tree
Hide file tree
Showing 39 changed files with 2,430 additions and 693 deletions.
14 changes: 7 additions & 7 deletions docs/api/expect.md
Expand Up @@ -138,7 +138,7 @@ type Awaitable<T> = T | PromiseLike<T>

```ts
import { Stocks } from './stocks.js'

const stocks = new Stocks()
stocks.sync('Bill')
if (stocks.getInfo('Bill'))
Expand All @@ -150,7 +150,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { expect, test } from 'vitest'
import { Stocks } from './stocks.js'

const stocks = new Stocks()

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

```ts
import { Stocks } from './stocks.js'

const stocks = new Stocks()
stocks.sync('Bill')
if (!stocks.stockFailed('Bill'))
Expand All @@ -183,7 +183,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { expect, test } from 'vitest'
import { Stocks } from './stocks.js'

const stocks = new Stocks()

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

```ts
import { expect, test } from 'vitest'

const actual = 'stock'

test('stock is type of string', () => {
Expand All @@ -259,7 +259,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { expect, test } from 'vitest'
import { Stocks } from './stocks.js'

const stocks = new Stocks()

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

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

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

Expand Down
71 changes: 69 additions & 2 deletions docs/api/vi.md
Expand Up @@ -114,11 +114,55 @@ import { vi } from 'vitest'

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

## vi.hoisted

- **Type**: `<T>(factory: () => T) => T`
- **Version**: Since Vitest 0.31.0

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.

Hovewer it can be useful to invoke some side effect like mocking dates before importing a module.

To bypass this limitation, you can rewrite static imports into dynamic ones like this:

```diff
callFunctionWithSideEffect()
- import { value } from './some/module.ts'
+ const { value } = await import('./some/module.ts')
```

When running `vitest`, you can do this automatically by using `vi.hoisted` method.

```diff
- callFunctionWithSideEffect()
import { value } from './some/module.ts'
+ vi.hoisted(() => callFunctionWithSideEffect())
```

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:

```ts
import { expect, vi } from 'vitest'
import { originalMethod } from './path/to/module.js'

const { mockedMethod } = vi.hoisted(() => {
return { mockedMethod: vi.fn() }
})

vi.mocked('./path/to/module.js', () => {
return { originalMethod: mockedMethod }
})

mockedMethod.mockReturnValue(100)
expect(originalMethod()).toBe(100)
```


## vi.mock

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

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.
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`.

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

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.

You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`:

```ts
import { namedExport } from './path/to/module.js'

const mocks = vi.hoisted(() => {
return {
namedExport: vi.fn(),
}
})

vi.mock('./path/to/module.js', () => {
return {
namedExport: mocks.namedExport,
}
})

vi.mocked(namedExport).mockReturnValue(100)

expect(namedExport()).toBe(100)
expect(namedExport).toBe(mocks.namedExport)
```
:::

::: warning
Expand Down Expand Up @@ -199,7 +266,7 @@ import { vi } from 'vitest'
```

::: warning
Beware that if you don't call `vi.mock`, modules **are not** mocked automatically.
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).
:::

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).
Expand Down
17 changes: 15 additions & 2 deletions docs/config/index.md
Expand Up @@ -963,7 +963,7 @@ Listen to port and serve API. When set to true, the default port is 51204

### browser

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

#### browser.slowHijackESM

- **Type:** `boolean`
- **Default:** `true`
- **Version:** Since Vitest 0.31.0

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.

This option has no effect on tests running inside Node.js.

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.


### clearMocks

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

- **Type:** `{ includeStack?, showDiff?, truncateThreshold? }`
- **Default:** `{ includeStack: false, showDiff: true, truncateThreshold: 40 }`
- **Version:** Vitest 0.30.0
- **Version:** Since Vitest 0.30.0

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

Expand Down
26 changes: 26 additions & 0 deletions examples/mocks/test/hoisted.test.ts
@@ -0,0 +1,26 @@
import { expect, test, vi } from 'vitest'
import { asyncSquare as importedAsyncSquare, square as importedSquare } from '../src/example'

const mocks = vi.hoisted(() => {
return {
square: vi.fn(),
}
})

const { asyncSquare } = await vi.hoisted(async () => {
return {
asyncSquare: vi.fn(),
}
})

vi.mock('../src/example.ts', () => {
return {
square: mocks.square,
asyncSquare,
}
})

test('hoisted works', () => {
expect(importedSquare).toBe(mocks.square)
expect(importedAsyncSquare).toBe(asyncSquare)
})
3 changes: 3 additions & 0 deletions examples/mocks/tsconfig.json
@@ -1,5 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"moduleResolution": "nodenext",
"types": ["vitest/globals"]
}
}
2 changes: 1 addition & 1 deletion examples/vue/test/__snapshots__/basic.test.ts.snap
@@ -1,4 +1,4 @@
// Vitest Snapshot v1
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`mount component 1`] = `
"<div>4 x 2 = 8</div>
Expand Down
6 changes: 5 additions & 1 deletion packages/browser/package.json
Expand Up @@ -39,17 +39,21 @@
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
"vitest": ">=0.29.4"
"vitest": ">=0.31.0"
},
"dependencies": {
"modern-node-polyfills": "^0.1.1",
"sirv": "^2.0.2"
},
"devDependencies": {
"@types/estree": "^1.0.1",
"@types/ws": "^8.5.4",
"@vitest/runner": "workspace:*",
"@vitest/ui": "workspace:*",
"@vitest/ws-client": "workspace:*",
"estree-walker": "^3.0.3",
"periscopic": "^3.1.0",
"rollup": "3.20.2",
"vitest": "workspace:*"
}
}
49 changes: 49 additions & 0 deletions packages/browser/src/client/index.html
Expand Up @@ -24,6 +24,55 @@
</head>
<body>
<iframe id="vitest-ui" src=""></iframe>
<script>
const moduleCache = new Map()

// this method receives a module object or "import" promise that it resolves and keeps track of
// and returns a hijacked module object that can be used to mock module exports
function wrapModule(module) {
if (module instanceof Promise) {
moduleCache.set(module, { promise: module, evaluated: false })
return module
.then(m => m.__vi_inject__)
.finally(() => moduleCache.delete(module))
}
return module.__vi_inject__
}

function exportAll(exports, sourceModule) {
// #1120 when a module exports itself it causes
// call stack error
if (exports === sourceModule)
return

if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
return

for (const key in sourceModule) {
if (key !== 'default') {
try {
Object.defineProperty(exports, key, {
enumerable: true,
configurable: true,
get: () => sourceModule[key],
})
}
catch (_err) { }
}
}
}

window.__vi_export_all__ = exportAll

// TODO: allow easier rewriting of import.meta.env
window.__vi_import_meta__ = {
env: {},
url: location.href,
}

window.__vi_module_cache__ = moduleCache
window.__vi_wrap_module__ = wrapModule
</script>
<script type="module" src="/main.ts"></script>
</body>
</html>
6 changes: 5 additions & 1 deletion packages/browser/src/client/main.ts
Expand Up @@ -8,6 +8,7 @@ import { setupConsoleLogSpy } from './logger'
import { createSafeRpc, rpc, rpcDone } from './rpc'
import { setupDialogsSpy } from './dialog'
import { BrowserSnapshotEnvironment } from './snapshot'
import { VitestBrowserClientMocker } from './mocker'

// @ts-expect-error mocking some node apis
globalThis.process = { env: {}, argv: [], cwd: () => '/', stdout: { write: () => {} }, nextTick: cb => cb() }
Expand Down Expand Up @@ -72,14 +73,17 @@ ws.addEventListener('open', async () => {
globalThis.__vitest_worker__ = {
config,
browserHashMap,
moduleCache: new Map(),
// @ts-expect-error untyped global for internal use
moduleCache: globalThis.__vi_module_cache__,
rpc: client.rpc,
safeRpc,
durations: {
environment: 0,
prepare: 0,
},
}
// @ts-expect-error mocking vitest apis
globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()

const paths = getQueryPaths()

Expand Down
25 changes: 25 additions & 0 deletions packages/browser/src/client/mocker.ts
@@ -0,0 +1,25 @@
function throwNotImplemented(name: string) {
throw new Error(`[vitest] ${name} is not implemented in browser environment yet.`)
}

export class VitestBrowserClientMocker {
public importActual() {
throwNotImplemented('importActual')
}

public importMock() {
throwNotImplemented('importMock')
}

public queueMock() {
throwNotImplemented('queueMock')
}

public queueUnmock() {
throwNotImplemented('queueUnmock')
}

public prepare() {
// TODO: prepare
}
}
3 changes: 2 additions & 1 deletion packages/browser/src/client/utils.ts
@@ -1,4 +1,5 @@
export function importId(id: string) {
const name = `/@id/${id}`
return import(name)
// @ts-expect-error mocking vitest apis
return __vi_wrap_module__(import(name))
}

0 comments on commit 0c09a40

Please sign in to comment.