[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix: fix `vi.importActual()` for virtual modules (#9772)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by

Hiroshi Ogawa
Claude Opus 4.6
and committed by
GitHub
(Mar 4, 2026, 5:04 PM +0100) 1e89ec02 4e6a23de

+202 -11
+145 -1
test/cli/test/mocking.test.ts
··· 1 + import type { RunVitestConfig } from '../../test-utils' 2 + import { setDefaultResultOrder } from 'node:dns' 1 3 import path from 'node:path' 2 - import { expect, test } from 'vitest' 4 + import { playwright } from '@vitest/browser-playwright' 5 + import { webdriverio } from '@vitest/browser-webdriverio' 6 + import { afterAll, expect, test } from 'vitest' 3 7 import { rolldownVersion } from 'vitest/node' 4 8 import { runInlineTests, runVitest } from '../../test-utils' 9 + 10 + // webdriver@9 sets dns.setDefaultResultOrder("ipv4first") on import, 11 + // which makes Vite resolve localhost to 127.0.0.1 and breaks other tests asserting "localhost" 12 + afterAll(() => setDefaultResultOrder('verbatim')) 5 13 6 14 test('setting resetMocks works if restoreMocks is also set', async () => { 7 15 const { stderr, testTree } = await runInlineTests({ ··· 132 140 } 133 141 `) 134 142 } 143 + }) 144 + 145 + function modeToConfig(mode: string): RunVitestConfig { 146 + if (mode === 'playwright') { 147 + return { 148 + browser: { 149 + enabled: true, 150 + provider: playwright(), 151 + instances: [{ browser: 'chromium' }], 152 + headless: true, 153 + }, 154 + } 155 + } 156 + if (mode === 'webdriverio') { 157 + return { 158 + browser: { 159 + enabled: true, 160 + provider: webdriverio(), 161 + instances: [{ browser: 'chrome' }], 162 + headless: true, 163 + }, 164 + } 165 + } 166 + return {} 167 + } 168 + 169 + test.for(['node', 'playwright', 'webdriverio'])('importOriginal for virtual modules (%s)', async (mode) => { 170 + const { stderr, errorTree, root } = await runInlineTests({ 171 + 'vitest.config.js': ` 172 + import { defineConfig } from 'vitest/config' 173 + export default defineConfig({ 174 + plugins: [{ 175 + name: 'virtual-test', 176 + resolveId(source) { 177 + if (source === 'virtual:my-module') { 178 + return "\\0" + source 179 + } 180 + }, 181 + load(id) { 182 + if (id === '\\0virtual:my-module') { 183 + return 'export const value = "original"' 184 + } 185 + }, 186 + }], 187 + }) 188 + `, 189 + './basic.test.js': ` 190 + import { test, expect, vi } from 'vitest' 191 + import { value } from 'virtual:my-module' 192 + 193 + vi.mock('virtual:my-module', async (importOriginal) => { 194 + const original = await importOriginal() 195 + return { value: original.value + '-modified' } 196 + }) 197 + 198 + test('importOriginal returns original virtual module exports', () => { 199 + expect(value).toBe('original-modified') 200 + }) 201 + `, 202 + }, modeToConfig(mode)) 203 + 204 + // webdriverio uses a server-side interceptor plugin whose load hook 205 + // intercepts the clean id, so importActual returns the mock instead 206 + // of the original module. This is a known limitation. 207 + if (mode === 'webdriverio') { 208 + const tree = errorTree() 209 + tree['basic.test.js'].__module_errors__ = tree['basic.test.js'].__module_errors__.map( 210 + (e: string) => e.replace(root, '<root>'), 211 + ) 212 + expect(tree).toMatchInlineSnapshot(` 213 + { 214 + "__unhandled_errors__": [ 215 + "[vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock", 216 + ], 217 + "basic.test.js": { 218 + "__module_errors__": [ 219 + "Failed to import test file <root>/basic.test.js", 220 + ], 221 + }, 222 + } 223 + `) 224 + } 225 + else { 226 + expect(stderr).toBe('') 227 + expect(errorTree()).toMatchInlineSnapshot(` 228 + { 229 + "basic.test.js": { 230 + "importOriginal returns original virtual module exports": "passed", 231 + }, 232 + } 233 + `) 234 + } 235 + }) 236 + 237 + test.for(['node', 'playwright', 'webdriverio'])('mocking virtual module without importOriginal skips loading original (%s)', async (mode) => { 238 + const { stderr, testTree } = await runInlineTests({ 239 + 'vitest.config.js': ` 240 + import { defineConfig } from 'vitest/config' 241 + export default defineConfig({ 242 + plugins: [{ 243 + name: 'virtual-test', 244 + resolveId(source) { 245 + if (source === 'virtual:my-module') { 246 + return "\\0" + source 247 + } 248 + }, 249 + load(id) { 250 + if (id === '\\0virtual:my-module') { 251 + throw new Error('virtual module load should not be called') 252 + } 253 + }, 254 + }], 255 + }) 256 + `, 257 + './basic.test.js': ` 258 + import { test, expect, vi } from 'vitest' 259 + import { value } from 'virtual:my-module' 260 + 261 + vi.mock('virtual:my-module', () => { 262 + return { value: 'mocked' } 263 + }) 264 + 265 + test('mock works without loading original', () => { 266 + expect(value).toBe('mocked') 267 + }) 268 + `, 269 + }, modeToConfig(mode)) 270 + 271 + expect(stderr).toBe('') 272 + expect(testTree()).toMatchInlineSnapshot(` 273 + { 274 + "basic.test.js": { 275 + "mock works without loading original": "passed", 276 + }, 277 + } 278 + `) 135 279 })
+14
packages/browser/src/node/plugin.ts
··· 49 49 } 50 50 next() 51 51 }) 52 + // strip _vitest_original query added by importActual so that 53 + // the plugin pipeline sees the original import id (e.g. virtual modules's load hook). 54 + server.middlewares.use((req, _res, next) => { 55 + if ( 56 + req.url?.includes('_vitest_original') 57 + && parentServer.project.config.browser.provider?.name === 'playwright' 58 + ) { 59 + req.url = req.url 60 + .replace(/[?&]_vitest_original(?=[&#]|$)/, '') 61 + .replace(/[?&]ext\b[^&#]*/, '') 62 + .replace(/\?$/, '') 63 + } 64 + next() 65 + }) 52 66 server.middlewares.use(createOrchestratorMiddleware(parentServer)) 53 67 server.middlewares.use(createTesterMiddleware(parentServer)) 54 68
+3 -1
packages/vitest/src/runtime/moduleRunner/moduleMocker.ts
··· 7 7 import { AutomockedModule, RedirectedModule } from '@vitest/mocker' 8 8 import { distDir } from '../../paths' 9 9 import { BareModuleMocker } from './bareModuleMocker' 10 + import { injectQuery } from './utils' 10 11 11 12 const spyModulePath = resolve(distDir, 'spy.js') 12 13 ··· 130 131 callstack?: string[] | null, 131 132 ): Promise<T> { 132 133 const { url } = await this.resolveId(rawId, importer) 133 - const node = await this.moduleRunner.fetchModule(url, importer) 134 + const actualUrl = injectQuery(url, '_vitest_original') 135 + const node = await this.moduleRunner.fetchModule(actualUrl, importer) 134 136 const result = await this.moduleRunner.cachedRequest( 135 137 node.url, 136 138 node,
+19 -9
packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts
··· 12 12 import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' 13 13 import { VitestMocker } from './moduleMocker' 14 14 import { VitestModuleRunner } from './moduleRunner' 15 + import { removeQuery } from './utils' 15 16 16 17 const { readFileSync } = fs 17 18 ··· 95 96 return vitest 96 97 } 97 98 99 + // strip _vitest_original query added by importActual so that 100 + // the plugin pipeline sees the original import id (e.g. virtual modules's load hook) 101 + const isImportActual = id.includes('_vitest_original') 102 + if (isImportActual) { 103 + id = removeQuery(id, '_vitest_original') 104 + } 105 + 98 106 const rawId = unwrapId(id) 99 107 resolvingModules.add(rawId) 100 108 ··· 103 111 await moduleRunner.mocker.resolveMocks() 104 112 } 105 113 106 - const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId) 107 - if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') { 108 - return { 109 - code: '', 110 - file: null, 111 - id: resolvedMock.id, 112 - url: resolvedMock.url, 113 - invalidate: false, 114 - mockedModule: resolvedMock, 114 + if (!isImportActual) { 115 + const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId) 116 + if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') { 117 + return { 118 + code: '', 119 + file: null, 120 + id: resolvedMock.id, 121 + url: resolvedMock.url, 122 + invalidate: false, 123 + mockedModule: resolvedMock, 124 + } 115 125 } 116 126 } 117 127
+21
packages/vitest/src/runtime/moduleRunner/utils.ts
··· 1 + // copied from vite/src/shared/utils.ts 2 + const postfixRE = /[?#].*$/ 3 + 4 + function cleanUrl(url: string): string { 5 + return url.replace(postfixRE, '') 6 + } 7 + function splitFileAndPostfix(path: string): { file: string; postfix: string } { 8 + const file = cleanUrl(path) 9 + return { file, postfix: path.slice(file.length) } 10 + } 11 + 12 + export function injectQuery(url: string, queryToInject: string): string { 13 + const { file, postfix } = splitFileAndPostfix(url) 14 + return `${file}?${queryToInject}${postfix[0] === '?' ? `&${postfix.slice(1)}` : /* hash only */ postfix}` 15 + } 16 + 17 + export function removeQuery(url: string, queryToRemove: string): string { 18 + return url 19 + .replace(new RegExp(`[?&]${queryToRemove}(?=[&#]|$)`), '') 20 + .replace(/\?$/, '') 21 + }