diff --git a/docs/api/vi.md b/docs/api/vi.md index 5c50cb9b0..cb091998c 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -139,15 +139,22 @@ vi.mock('./path/to/module.js', () => { ::: -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. +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: + +- 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. +- 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. + +Vitest treats files in other [`deps.moduleDirectories`](/config/deps#deps-moduledirectories) the same as files in `node_modules`. For example, you have this file structure: ``` - __mocks__ + - @company + - utils.js - axios.js - src - __mocks__ + - __mocks__ - increment.js - increment.js - tests @@ -162,13 +169,18 @@ import { vi } from 'vitest' // axios is a default export from `__mocks__/axios.js` import axios from 'axios' +// format is a named export from `__mocks__/@company/utils.js`, +// even if `@company/utils` is a workspace package +import { format } from '@company/utils' + // increment is a named export from `src/__mocks__/increment.js` -import { increment } from '../increment.js' +import { increment } from '../src/increment.js' vi.mock('axios') -vi.mock('../increment.js') +vi.mock('@company/utils') +vi.mock('../src/increment.js') -axios.get(`/apples/${increment(1)}`) +axios.get(`/apples/${format(increment(1))}`) ``` ::: warning diff --git a/docs/config/deps.md b/docs/config/deps.md index dad21d838..220f7579d 100644 --- a/docs/config/deps.md +++ b/docs/config/deps.md @@ -113,11 +113,13 @@ By default, Vitest assumes you are using a bundler to bypass this and will not f - **Type:** `string[]` - **Default:** `['node_modules']` -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. +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`. -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. +Vitest treats a file inside one of these directories as a dependency: -Setting this option will _override_ the default, if you wish to still search `node_modules` for packages include it along with any other options: +- [`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. +- 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. +- 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. ```ts import { defineConfig } from 'vitest/config' @@ -125,8 +127,16 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { deps: { - moduleDirectories: ['node_modules', path.resolve('../../packages')], - } + moduleDirectories: ['vendor'], + }, }, }) ``` + +::: tip +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`. +::: + +::: warning +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. +::: diff --git a/docs/config/server.md b/docs/config/server.md index e7c3623e2..2f16741b2 100644 --- a/docs/config/server.md +++ b/docs/config/server.md @@ -20,7 +20,7 @@ Normally, Vitest should do this automatically. ### server.deps.external - **Type:** `(string | RegExp)[]` -- **Default:** files inside [`moduleDirectories`](/config/deps#moduledirectories) +- **Default:** files inside [`moduleDirectories`](/config/deps#deps-moduledirectories) 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. @@ -43,7 +43,7 @@ External modules and their dependencies are not present in the module graph and Typically, packages under `node_modules` are externalized. ::: tip -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`. +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`. If a `RegExp` is provided, it is matched against the full file path. ::: @@ -58,7 +58,7 @@ Specifies modules that should be transformed and resolved by Vite. These modules Typically, your source files are inlined. ::: tip -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`. +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`. If a `RegExp` is provided, it is matched against the full file path. ::: diff --git a/packages/mocker/src/node/redirect.ts b/packages/mocker/src/node/redirect.ts index db9b1ae94..ebd6cfa0a 100644 --- a/packages/mocker/src/node/redirect.ts +++ b/packages/mocker/src/node/redirect.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import nodeModule from 'node:module' +import { isBareImport } from '@vitest/utils/helpers' import { basename, dirname, extname, join, resolve } from 'pathe' const { existsSync, readdirSync, statSync } = fs @@ -8,43 +9,22 @@ export function findMockRedirect( root: string, mockPath: string, external: string | null, + rawId?: string, ): string | null { const path = external || mockPath // it's a node_module alias // all mocks should be inside /__mocks__ if (external || isNodeBuiltin(mockPath) || !existsSync(mockPath)) { - const mockDirname = dirname(path) // for nested mocks: @vueuse/integration/useJwt - const mockFolder = join(root, '__mocks__', mockDirname) + return findRootMock(root, path) + } - if (!existsSync(mockFolder)) { - return null + // a linked package (like a workspace dependency) resolves outside of node_modules + if (rawId && isBareImport(rawId)) { + const rootMock = findRootMock(root, rawId) + if (rootMock) { + return rootMock } - - const baseOriginal = basename(path) - - function findFile(mockFolder: string, baseOriginal: string): string | null { - const files = readdirSync(mockFolder) - for (const file of files) { - const baseFile = basename(file, extname(file)) - if (baseFile === baseOriginal) { - const path = resolve(mockFolder, file) - // if the same name, return the file - if (statSync(path).isFile()) { - return path - } else { - // find folder/index.{js,ts} - const indexFile = findFile(path, 'index') - if (indexFile) { - return indexFile - } - } - } - } - return null - } - - return findFile(mockFolder, baseOriginal) } const dir = dirname(path) @@ -53,6 +33,38 @@ export function findMockRedirect( return existsSync(fullPath) ? fullPath : null } +function findRootMock(root: string, path: string): string | null { + const mockDirname = dirname(path) // for nested mocks: @vueuse/integration/useJwt + const mockFolder = join(root, '__mocks__', mockDirname) + + if (!existsSync(mockFolder)) { + return null + } + + return findFile(mockFolder, basename(path)) +} + +function findFile(mockFolder: string, baseOriginal: string): string | null { + const files = readdirSync(mockFolder) + for (const file of files) { + const baseFile = basename(file, extname(file)) + if (baseFile === baseOriginal) { + const path = resolve(mockFolder, file) + // if the same name, return the file + if (statSync(path).isFile()) { + return path + } else { + // find folder/index.{js,ts} + const indexFile = findFile(path, 'index') + if (indexFile) { + return indexFile + } + } + } + } + return null +} + const builtins = new Set([ ...nodeModule.builtinModules, 'assert/strict', diff --git a/packages/mocker/src/node/resolver.ts b/packages/mocker/src/node/resolver.ts index ccee56a20..850087d18 100644 --- a/packages/mocker/src/node/resolver.ts +++ b/packages/mocker/src/node/resolver.ts @@ -38,7 +38,7 @@ export class ServerMockResolver { return { mockType: 'autospy', resolvedId: id, resolvedUrl } } - const redirectUrl = findMockRedirect(this.server.config.root, fsPath, external) + const redirectUrl = findMockRedirect(this.server.config.root, fsPath, external, rawId) return { mockType: redirectUrl === null ? 'automock' : 'redirect', diff --git a/packages/vitest/src/node/environments/staticMocks.ts b/packages/vitest/src/node/environments/staticMocks.ts index 0b7d53c07..7c34d59a6 100644 --- a/packages/vitest/src/node/environments/staticMocks.ts +++ b/packages/vitest/src/node/environments/staticMocks.ts @@ -89,5 +89,5 @@ function findRedirect( const moduleDirectories = config.deps.moduleDirectories ?? ['/node_modules/'] const external = !isAbsolute(id) || moduleDirectories.some((dir) => id.includes(dir)) ? specifier : null - return findMockRedirect(config.root, cleanUrl(id), external) + return findMockRedirect(config.root, cleanUrl(id), external, specifier) } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 3cfee7e15..3c881aca5 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -216,7 +216,8 @@ interface DepsOptions { interopDefault?: boolean /** - * A list of directories relative to the config file that should be treated as module directories. + * A list of directories that contain dependencies. A file is a dependency if its path includes `//`. + * `node_modules` is always included. * * @default ['node_modules'] */ diff --git a/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts index b11b0922f..f566e5164 100644 --- a/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts @@ -232,8 +232,12 @@ export class BareModuleMocker implements TestModuleMocker { return registry.get(url) } - public findMockRedirect(mockPath: string, external: string | null): string | null { - return findMockRedirect(this.root, mockPath, external) + public findMockRedirect( + mockPath: string, + external: string | null, + rawId?: string, + ): string | null { + return findMockRedirect(this.root, mockPath, external, rawId) } public mockObject( @@ -297,7 +301,7 @@ export class BareModuleMocker implements TestModuleMocker { } else if (mockType === 'autospy') { registry.register('autospy', originalId, id, url) } else { - const redirect = this.findMockRedirect(id, external) + const redirect = this.findMockRedirect(id, external, originalId) if (redirect) { registry.register('redirect', originalId, id, url, redirect) } else { diff --git a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts index 83cede027..e96249095 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts @@ -305,7 +305,7 @@ export class VitestMocker extends BareModuleMocker { let mock = this.getDependencyMock(id) if (!mock) { - const redirect = this.findMockRedirect(id, external) + const redirect = this.findMockRedirect(id, external, rawId) if (redirect) { mock = new RedirectedModule(rawId, id, rawId, redirect) } else { diff --git a/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts b/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts index 1259521d1..8ee502d3c 100644 --- a/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts @@ -245,7 +245,7 @@ export { __${index} as "${key}" } const external = !isAbsolute(filename) || this.isModuleDirectory(resolvedId) ? normalizeModuleId(rawId) : null // file is not mocked, automock or redirect it - const redirect = this.findMockRedirect(filename, external) + const redirect = this.findMockRedirect(filename, external, rawId) if (redirect) { return import(pathToFileURL(redirect).toString()) } diff --git a/test/e2e/test/mocking.test.ts b/test/e2e/test/mocking.test.ts index 59d64930b..44c253968 100644 --- a/test/e2e/test/mocking.test.ts +++ b/test/e2e/test/mocking.test.ts @@ -825,6 +825,49 @@ test('mock works without loading original', () => { }, ) +// https://github.com/vitest-dev/vitest/issues/7708 +test.for(['node', 'playwright'])( + 'root __mocks__ applies to a linked package (%s)', + async (mode) => { + const { stderr, errorTree } = await runInlineTests( + { + './__mocks__/@test/test-dep-linked/ts.js': `export default 'mocked'`, + './mock.test.js': ` +import { test, expect, vi } from 'vitest' +import value from '@test/test-dep-linked/ts' + +vi.mock('@test/test-dep-linked/ts') + +test('vi.mock loads the mock', () => { + expect(value).toBe('mocked') +}) + `, + './import-mock.test.js': ` +import { test, expect, vi } from 'vitest' + +test('vi.importMock loads the mock', async () => { + const mod = await vi.importMock('@test/test-dep-linked/ts') + expect(mod.default).toBe('mocked') +}) + `, + }, + modeToConfig(mode), + ) + + expect(stderr).toBe('') + expect(errorTree()).toMatchInlineSnapshot(` + { + "import-mock.test.js": { + "vi.importMock loads the mock": "passed", + }, + "mock.test.js": { + "vi.mock loads the mock": "passed", + }, + } + `) + }, +) + // https://github.com/vitest-dev/vitest/issues/8653 test.for(['node', 'playwright'])( 'in-source vi.mock applies only when its file is the test file (%s)', diff --git a/test/e2e/test/no-module-runner.test.ts b/test/e2e/test/no-module-runner.test.ts index 264141b11..4650e82f8 100644 --- a/test/e2e/test/no-module-runner.test.ts +++ b/test/e2e/test/no-module-runner.test.ts @@ -194,6 +194,37 @@ describe.runIf(module.registerHooks)('supported', () => { `) }) + test('root __mocks__ applies to a linked package', async () => { + const { stderr, testTree } = await runNoViteModuleRunnerTests({ + '__mocks__/@test/test-dep-linked/ts.js': `export default 'mocked'`, + 'mock.test.js': ` + import value from '@test/test-dep-linked/ts' + vi.mock('@test/test-dep-linked/ts') + test('vi.mock loads the mock', () => { + expect(value).toBe('mocked') + }) + `, + 'import-mock.test.js': ` + test('vi.importMock loads the mock', async () => { + const mod = await vi.importMock('@test/test-dep-linked/ts') + expect(mod.default).toBe('mocked') + }) + `, + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "import-mock.test.js": { + "vi.importMock loads the mock": "passed", + }, + "mock.test.js": { + "vi.mock loads the mock": "passed", + }, + } + `) + }) + test('updating inline snapshots works', async () => { const { fs, stdout } = await runNoViteModuleRunnerTests( { diff --git a/test/e2e/test/related-deps.test.ts b/test/e2e/test/related-deps.test.ts index 898a051a9..4193bce71 100644 --- a/test/e2e/test/related-deps.test.ts +++ b/test/e2e/test/related-deps.test.ts @@ -519,6 +519,33 @@ test('a manual mock from __mocks__ runs only the test that mocks it', async () = `) }) +test('a root __mocks__ file of a linked package runs only the test that mocks it', async () => { + const { stderr, testTree } = await runInlineTests( + { + '__mocks__/@test/test-dep-linked/ts.js': `export default 'mocked'`, + 'a.test.js': ` + import { test, vi } from 'vitest' + import '@test/test-dep-linked/ts' + + vi.mock('@test/test-dep-linked/ts') + + test('a', () => {}) + `, + 'b.test.js': testFile('b', `import '@test/test-dep-linked/ts'`), + }, + { related: ['__mocks__/@test/test-dep-linked/ts.js'] }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "a.test.js": { + "a": "passed", + }, + } + `) +}) + test('the environment comment is read when filtering and reused by the run', async () => { const root = `${process.cwd()}/vitest-test-${crypto.randomUUID()}` useFS(root, {