Visitar URL original
fix(mocker): use root __mocks__ for linked packages (fix #7708) (#11739) · vitest-dev/vitest@810ec66 · GitHub
Skip to content

Commit 810ec66

Browse files
authored
fix(mocker): use root __mocks__ for linked packages (fix #7708) (#11739)
1 parent 49ff442 commit 810ec66

13 files changed

Lines changed: 190 additions & 50 deletions

File tree

‎docs/api/vi.md‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,22 @@ vi.mock('./path/to/module.js', () => {
139139

140140
:::
141141

142-
If there is a `__mocks__` folder alongside a file that you are mocking, and the factory is not provided, Vitest will try to find a file with the same name in the `__mocks__` subfolder and use it as an actual module. If you are mocking a dependency, Vitest will try to find a `__mocks__` folder in the [root](/config/root) of the project (default is `process.cwd()`). You can tell Vitest where the dependencies are located through the [`deps.moduleDirectories`](/config/deps#deps-moduledirectories) config option.
142+
If the factory is not provided, Vitest looks for a file with the same name in a `__mocks__` folder and uses it instead of the original module:
143+
144+
- If the path is a package name, like `axios` or `@company/utils/format`, Vitest looks in the `__mocks__` folder in the [root](/config/root) of the project (default is `process.cwd()`) for a file with this path and any extension, for example, `__mocks__/@company/utils/format.ts`. This also applies to a package that is linked from outside of `node_modules`, like a workspace package in a monorepo.
145+
- If the module resolves to a file outside of `node_modules`, Vitest looks in the `__mocks__` folder next to that file for a file with the same name and extension, for example, `src/__mocks__/increment.js` for `src/increment.js`. For a package name, Vitest does this only if the root `__mocks__` folder doesn't have the file.
146+
147+
Vitest treats files in other [`deps.moduleDirectories`](/config/deps#deps-moduledirectories) the same as files in `node_modules`.
143148

144149
For example, you have this file structure:
145150

146151
```
147152
- __mocks__
153+
- @company
154+
- utils.js
148155
- axios.js
149156
- src
150-
__mocks__
157+
- __mocks__
151158
- increment.js
152159
- increment.js
153160
- tests
@@ -162,13 +169,18 @@ import { vi } from 'vitest'
162169
// axios is a default export from `__mocks__/axios.js`
163170
import axios from 'axios'
164171
172+
// format is a named export from `__mocks__/@company/utils.js`,
173+
// even if `@company/utils` is a workspace package
174+
import { format } from '@company/utils'
175+
165176
// increment is a named export from `src/__mocks__/increment.js`
166-
import { increment } from '../increment.js'
177+
import { increment } from '../src/increment.js'
167178
168179
vi.mock('axios')
169-
vi.mock('../increment.js')
180+
vi.mock('@company/utils')
181+
vi.mock('../src/increment.js')
170182
171-
axios.get(`/apples/${increment(1)}`)
183+
axios.get(`/apples/${format(increment(1))}`)
172184
```
173185

174186
::: warning

‎docs/config/deps.md‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,30 @@ By default, Vitest assumes you are using a bundler to bypass this and will not f
113113
- **Type:** `string[]`
114114
- **Default:** `['node_modules']`
115115

116-
A list of directories that should be treated as module directories. This config option affects the behavior of [`vi.mock`](/api/vi#vi-mock): when no factory is provided and the path of what you are mocking matches one of the `moduleDirectories` values, Vitest will try to resolve the mock by looking for a `__mocks__` folder in the [root](/config/root) of the project.
116+
A list of directories that contain dependencies. Vitest adds a slash before and after each value and checks if the path of a file includes it: `'vendor'` matches every file inside a `vendor` folder, and an absolute path matches only the files inside that folder. Vitest always includes `node_modules`.
117117

118-
This option will also affect if a file should be treated as a module when externalizing dependencies. By default, Vitest imports external modules with native Node.js bypassing Vite transformation step.
118+
Vitest treats a file inside one of these directories as a dependency:
119119

120-
Setting this option will _override_ the default, if you wish to still search `node_modules` for packages include it along with any other options:
120+
- [`vi.mock`](/api/vi#vi-mock) without a factory looks for the mock only in the `__mocks__` folder in the [root](/config/root) of the project.
121+
- Vitest [externalizes](/config/server#server-deps-external) JavaScript files that Node.js can import directly, for example, a `.js` file in a package with `"type": "module"`. Vitest imports these files with native Node.js, Vite plugins don't transform them, and `vi.mock` doesn't apply to the modules that they import.
122+
- Vitest prefixes string values of [`server.deps.inline`](/config/server#server-deps-inline) and [`server.deps.external`](/config/server#server-deps-external) with these directories.
121123

122124
```ts
123125
import { defineConfig } from 'vitest/config'
124126

125127
export default defineConfig({
126128
test: {
127129
deps: {
128-
moduleDirectories: ['node_modules', path.resolve('../../packages')],
129-
}
130+
moduleDirectories: ['vendor'],
131+
},
130132
},
131133
})
132134
```
135+
136+
::: tip
137+
You don't need this option to mock a workspace package in a monorepo. If you call `vi.mock` with a package name, Vitest looks in the root `__mocks__` folder even if the package resolves outside of `node_modules`.
138+
:::
139+
140+
::: warning
141+
Don't add a directory that contains the source files of your project. For example, if the project is in `packages/app`, the value `packages` makes Vitest treat the files in `packages/app` as dependencies too.
142+
:::

‎docs/config/server.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Normally, Vitest should do this automatically.
2020
### server.deps.external
2121

2222
- **Type:** `(string | RegExp)[]`
23-
- **Default:** files inside [`moduleDirectories`](/config/deps#moduledirectories)
23+
- **Default:** files inside [`moduleDirectories`](/config/deps#deps-moduledirectories)
2424

2525
Specifies modules that should not be transformed by Vite and should instead be processed directly by the engine. These modules are imported via native dynamic `import` and bypass both transformation and resolution phases.
2626

@@ -43,7 +43,7 @@ External modules and their dependencies are not present in the module graph and
4343
Typically, packages under `node_modules` are externalized.
4444

4545
::: tip
46-
If a string is provided, it is first normalized by prefixing the `/node_modules/` or other [`moduleDirectories`](/config/deps#moduledirectories) segments (for example, `'react'` becomes `/node_modules/react/`), and the resulting string is then matched against the full file path. For example, package `@company/some-name` located inside `packages/some-name` should be specified as `some-name`, and `packages` should be included in `deps.moduleDirectories`.
46+
If a string is provided, it is first normalized by prefixing the `/node_modules/` or other [`moduleDirectories`](/config/deps#deps-moduledirectories) segments (for example, `'react'` becomes `/node_modules/react/`), and the resulting string is then matched against the full file path. For example, package `@company/some-name` located inside `packages/some-name` should be specified as `some-name`, and `packages` should be included in `deps.moduleDirectories`.
4747

4848
If a `RegExp` is provided, it is matched against the full file path.
4949
:::
@@ -58,7 +58,7 @@ Specifies modules that should be transformed and resolved by Vite. These modules
5858
Typically, your source files are inlined.
5959

6060
::: tip
61-
If a string is provided, it is first normalized by prefixing the `/node_modules/` or other [`moduleDirectories`](/config/deps#moduledirectories) segments (for example, `'react'` becomes `/node_modules/react/`), and the resulting string is then matched against the full file path. For example, package `@company/some-name` located inside `packages/some-name` should be specified as `some-name`, and `packages` should be included in `deps.moduleDirectories`.
61+
If a string is provided, it is first normalized by prefixing the `/node_modules/` or other [`moduleDirectories`](/config/deps#deps-moduledirectories) segments (for example, `'react'` becomes `/node_modules/react/`), and the resulting string is then matched against the full file path. For example, package `@company/some-name` located inside `packages/some-name` should be specified as `some-name`, and `packages` should be included in `deps.moduleDirectories`.
6262

6363
If a `RegExp` is provided, it is matched against the full file path.
6464
:::

‎packages/mocker/src/node/redirect.ts‎

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'node:fs'
22
import nodeModule from 'node:module'
3+
import { isBareImport } from '@vitest/utils/helpers'
34
import { basename, dirname, extname, join, resolve } from 'pathe'
45

56
const { existsSync, readdirSync, statSync } = fs
@@ -8,43 +9,22 @@ export function findMockRedirect(
89
root: string,
910
mockPath: string,
1011
external: string | null,
12+
rawId?: string,
1113
): string | null {
1214
const path = external || mockPath
1315

1416
// it's a node_module alias
1517
// all mocks should be inside <root>/__mocks__
1618
if (external || isNodeBuiltin(mockPath) || !existsSync(mockPath)) {
17-
const mockDirname = dirname(path) // for nested mocks: @vueuse/integration/useJwt
18-
const mockFolder = join(root, '__mocks__', mockDirname)
19+
return findRootMock(root, path)
20+
}
1921

20-
if (!existsSync(mockFolder)) {
21-
return null
22+
// a linked package (like a workspace dependency) resolves outside of node_modules
23+
if (rawId && isBareImport(rawId)) {
24+
const rootMock = findRootMock(root, rawId)
25+
if (rootMock) {
26+
return rootMock
2227
}
23-
24-
const baseOriginal = basename(path)
25-
26-
function findFile(mockFolder: string, baseOriginal: string): string | null {
27-
const files = readdirSync(mockFolder)
28-
for (const file of files) {
29-
const baseFile = basename(file, extname(file))
30-
if (baseFile === baseOriginal) {
31-
const path = resolve(mockFolder, file)
32-
// if the same name, return the file
33-
if (statSync(path).isFile()) {
34-
return path
35-
} else {
36-
// find folder/index.{js,ts}
37-
const indexFile = findFile(path, 'index')
38-
if (indexFile) {
39-
return indexFile
40-
}
41-
}
42-
}
43-
}
44-
return null
45-
}
46-
47-
return findFile(mockFolder, baseOriginal)
4828
}
4929

5030
const dir = dirname(path)
@@ -53,6 +33,38 @@ export function findMockRedirect(
5333
return existsSync(fullPath) ? fullPath : null
5434
}
5535

36+
function findRootMock(root: string, path: string): string | null {
37+
const mockDirname = dirname(path) // for nested mocks: @vueuse/integration/useJwt
38+
const mockFolder = join(root, '__mocks__', mockDirname)
39+
40+
if (!existsSync(mockFolder)) {
41+
return null
42+
}
43+
44+
return findFile(mockFolder, basename(path))
45+
}
46+
47+
function findFile(mockFolder: string, baseOriginal: string): string | null {
48+
const files = readdirSync(mockFolder)
49+
for (const file of files) {
50+
const baseFile = basename(file, extname(file))
51+
if (baseFile === baseOriginal) {
52+
const path = resolve(mockFolder, file)
53+
// if the same name, return the file
54+
if (statSync(path).isFile()) {
55+
return path
56+
} else {
57+
// find folder/index.{js,ts}
58+
const indexFile = findFile(path, 'index')
59+
if (indexFile) {
60+
return indexFile
61+
}
62+
}
63+
}
64+
}
65+
return null
66+
}
67+
5668
const builtins = new Set([
5769
...nodeModule.builtinModules,
5870
'assert/strict',

‎packages/mocker/src/node/resolver.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class ServerMockResolver {
3838
return { mockType: 'autospy', resolvedId: id, resolvedUrl }
3939
}
4040

41-
const redirectUrl = findMockRedirect(this.server.config.root, fsPath, external)
41+
const redirectUrl = findMockRedirect(this.server.config.root, fsPath, external, rawId)
4242

4343
return {
4444
mockType: redirectUrl === null ? 'automock' : 'redirect',

‎packages/vitest/src/node/environments/staticMocks.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,5 @@ function findRedirect(
8989
const moduleDirectories = config.deps.moduleDirectories ?? ['/node_modules/']
9090
const external =
9191
!isAbsolute(id) || moduleDirectories.some((dir) => id.includes(dir)) ? specifier : null
92-
return findMockRedirect(config.root, cleanUrl(id), external)
92+
return findMockRedirect(config.root, cleanUrl(id), external, specifier)
9393
}

‎packages/vitest/src/node/types/config.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ interface DepsOptions {
216216
interopDefault?: boolean
217217

218218
/**
219-
* A list of directories relative to the config file that should be treated as module directories.
219+
* A list of directories that contain dependencies. A file is a dependency if its path includes `/<directory>/`.
220+
* `node_modules` is always included.
220221
*
221222
* @default ['node_modules']
222223
*/

‎packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,12 @@ export class BareModuleMocker implements TestModuleMocker {
232232
return registry.get(url)
233233
}
234234

235-
public findMockRedirect(mockPath: string, external: string | null): string | null {
236-
return findMockRedirect(this.root, mockPath, external)
235+
public findMockRedirect(
236+
mockPath: string,
237+
external: string | null,
238+
rawId?: string,
239+
): string | null {
240+
return findMockRedirect(this.root, mockPath, external, rawId)
237241
}
238242

239243
public mockObject(
@@ -297,7 +301,7 @@ export class BareModuleMocker implements TestModuleMocker {
297301
} else if (mockType === 'autospy') {
298302
registry.register('autospy', originalId, id, url)
299303
} else {
300-
const redirect = this.findMockRedirect(id, external)
304+
const redirect = this.findMockRedirect(id, external, originalId)
301305
if (redirect) {
302306
registry.register('redirect', originalId, id, url, redirect)
303307
} else {

‎packages/vitest/src/runtime/moduleRunner/moduleMocker.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export class VitestMocker extends BareModuleMocker {
305305
let mock = this.getDependencyMock(id)
306306

307307
if (!mock) {
308-
const redirect = this.findMockRedirect(id, external)
308+
const redirect = this.findMockRedirect(id, external, rawId)
309309
if (redirect) {
310310
mock = new RedirectedModule(rawId, id, rawId, redirect)
311311
} else {

‎packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export { __${index} as "${key}" }
245245
const external =
246246
!isAbsolute(filename) || this.isModuleDirectory(resolvedId) ? normalizeModuleId(rawId) : null
247247
// file is not mocked, automock or redirect it
248-
const redirect = this.findMockRedirect(filename, external)
248+
const redirect = this.findMockRedirect(filename, external, rawId)
249249
if (redirect) {
250250
return import(pathToFileURL(redirect).toString())
251251
}

0 commit comments

Comments
 (0)