Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: update mock implementation to support ESM runtime, introduce "vi.hoisted" #3258

Merged
merged 23 commits into from Apr 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6de38b7
feat: update mock implementation to support ESM runtime, introduce "v…
sheremet-va Apr 25, 2023
b21e0d9
chore: init mocker
sheremet-va Apr 25, 2023
f8b31ac
chore: throw error on "browser" in poolMatchGlobs
sheremet-va Apr 25, 2023
5f72426
chore: support "vi.dynamicImportSettled"
sheremet-va Apr 25, 2023
50acc80
chore: cleanup
sheremet-va Apr 25, 2023
a34b216
test: add test for hoisted
sheremet-va Apr 25, 2023
97d450e
docs: vi.hoisted
sheremet-va Apr 25, 2023
69895fb
chore: fix docs
sheremet-va Apr 25, 2023
9edd01b
refactor: rewrite every import like a psychopath
sheremet-va Apr 26, 2023
92c72e7
fix: actually hoist vi.mock
sheremet-va Apr 26, 2023
06f4805
chore: return "vi" access check
sheremet-va Apr 26, 2023
8b2d633
chore: check on globalThis
sheremet-va Apr 26, 2023
ab20e9e
chore: remove log
sheremet-va Apr 26, 2023
6354e64
chore: don't rewrite skipped imports
sheremet-va Apr 26, 2023
b79db39
chore: use rollup parse function to parse files
sheremet-va Apr 26, 2023
f2995b8
chore: cleanup
sheremet-va Apr 26, 2023
8166d02
test: add more tests for injector
sheremet-va Apr 26, 2023
4d674b9
chore: refactor mock hoisting and esm injector into two separate func…
sheremet-va Apr 27, 2023
948530c
chore: update lockfile
sheremet-va Apr 27, 2023
b3932b7
chore: fix slowHijack docs
sheremet-va Apr 27, 2023
b295ccd
chore: cleanup
sheremet-va Apr 27, 2023
ada7d49
Merge branch 'main' into feat/return-mocks-plugin
sheremet-va Apr 27, 2023
1da66d2
chore: remove "hijackESM" option from the plugin
sheremet-va Apr 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -954,7 +954,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 @@ -1026,6 +1026,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 @@ -1349,7 +1362,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))
}