Skip to content

Commit 36d19cb

Browse files
authored
fix: wrap module mocks during in-source testing (#11505)
1 parent 836ddfa commit 36d19cb

6 files changed

Lines changed: 333 additions & 48 deletions

File tree

‎packages/browser/src/node/plugin.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export default (contribution: BrowserServerContribution): Plugin[] => {
6161
}),
6262
{
6363
name: 'vitest:browser:in-source-tests',
64+
// runs after `vitest:mocks`, so hoisted mocks can read `import.meta.vitest`
65+
enforce: 'post',
6466
transform: {
6567
filter: {
6668
code: /import\.meta\.vitest/,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ interface Visitors {
4242
onIdentifier?: (node: Positioned<Identifier>, info: IdentifierInfo, parentStack: Node[]) => void
4343
onImportMeta?: (node: Positioned<MetaProperty>) => void
4444
onDynamicImport?: (node: Positioned<ImportExpression>) => void
45-
onCallExpression?: (node: Positioned<CallExpression>) => void
45+
onCallExpression?: (node: Positioned<CallExpression>, parentStack: Node[]) => void
4646
}
4747

4848
const isNodeInPatternWeakSet = new WeakSet<_Node>()
@@ -126,7 +126,7 @@ export function esmWalker(
126126
}
127127

128128
if (node.type === 'CallExpression') {
129-
onCallExpression?.(node as Positioned<CallExpression>)
129+
onCallExpression?.(node as Positioned<CallExpression>, parentStack)
130130
}
131131

132132
if (node.type === 'MetaProperty' && node.meta.name === 'import') {

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

Lines changed: 78 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type {
22
ArrowFunctionExpression,
33
AwaitExpression,
4+
BlockStatement,
45
CallExpression,
56
ExportDefaultDeclaration,
67
ExportNamedDeclaration,
78
Expression,
89
FunctionExpression,
910
Identifier,
11+
IfStatement,
1012
ImportDeclaration,
1113
SpreadElement,
1214
VariableDeclaration,
@@ -256,17 +258,38 @@ export function hoistMocks(
256258
}
257259
}
258260

261+
function isImportMetaVitestCheck(node: Node): node is Positioned<IfStatement> {
262+
if (node.type !== 'IfStatement') {
263+
return false
264+
}
265+
const test = node.test as Positioned<Expression>
266+
return code.slice(test.start, test.end) === 'import.meta.vitest'
267+
}
268+
269+
const hoistConditions = new Map<Node, string>()
270+
const inSourceBlocks = new Set<BlockStatement>()
271+
272+
// in-source `vi.mock` and `vi.hoisted` run only when their file is the test file
273+
function addHoistedNode(
274+
node: Positioned<CallExpression | VariableDeclaration | AwaitExpression>,
275+
parentStack: Node[],
276+
) {
277+
hoistedNodes.add(node)
278+
const guard = parentStack.find(isImportMetaVitestCheck)
279+
if (guard) {
280+
const test = guard.test as Positioned<Expression>
281+
// copied because the native loader rewrites `import.meta.vitest`
282+
hoistConditions.set(node, s.slice(test.start, test.end))
283+
if (guard.consequent.type === 'BlockStatement') {
284+
inSourceBlocks.add(guard.consequent)
285+
}
286+
}
287+
}
288+
259289
const usedUtilityExports = new Set<string>()
260-
let hasImportMetaVitest = false
261290
let hasMockApiCall = false
262291

263292
esmWalker(ast, {
264-
onImportMeta(node) {
265-
const property = code.slice(node.end, node.end + 7) // '.vitest'.length
266-
if (property === '.vitest') {
267-
hasImportMetaVitest = true
268-
}
269-
},
270293
onIdentifier(id, info, parentStack) {
271294
const binding = idToImportMap.get(id.name)
272295
if (!binding) {
@@ -307,7 +330,7 @@ export function hoistMocks(
307330
// )
308331
// s.overwrite(node.end - 1, node.end, '))')
309332
},
310-
onCallExpression(node) {
333+
onCallExpression(node, parentStack) {
311334
if (
312335
node.callee.type === 'MemberExpression' &&
313336
isIdentifier(node.callee.object) &&
@@ -373,7 +396,7 @@ export function hoistMocks(
373396
s.overwrite(moduleInfo.start, moduleInfo.end, s.slice(source.start, source.end))
374397
}
375398
}
376-
hoistedNodes.add(node)
399+
addHoistedNode(node, parentStack)
377400
}
378401
// vi.doMock(import('./path')) -> vi.doMock('./path')
379402
// vi.doMock(await import('./path')) -> vi.doMock('./path')
@@ -421,14 +444,14 @@ export function hoistMocks(
421444
'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.',
422445
)
423446
// hoist "const variable = vi.hoisted(() => {})"
424-
hoistedNodes.add(declarationNode)
447+
addHoistedNode(declarationNode, parentStack)
425448
} else {
426449
const awaitedExpression = findNodeAround(ast, node.start, 'AwaitExpression')?.node as
427450
| Positioned<AwaitExpression>
428451
| undefined
429452
// hoist "await vi.hoisted(async () => {})" or "vi.hoisted(() => {})"
430453
const moveNode = awaitedExpression?.argument === node ? awaitedExpression : node
431-
hoistedNodes.add(moveNode)
454+
addHoistedNode(moveNode, parentStack)
432455
}
433456
}
434457
}
@@ -503,45 +526,57 @@ export function hoistMocks(
503526
}
504527
}
505528

506-
// validate that hoisted nodes are defined on the top level
507-
// ignore `import.meta.vitest` because it needs to be inside an IfStatement
508-
// and it can be used anywhere in the code (inside methods too)
509-
if (!hasImportMetaVitest) {
510-
for (const node of ast.body as Node[]) {
511-
hoistedNodes.delete(node as any)
512-
if (node.type === 'ExpressionStatement') {
513-
hoistedNodes.delete(node.expression as any)
514-
}
529+
// validate that hoisted nodes are defined on the top level of the module
530+
// or of an `if (import.meta.vitest)` block
531+
const topLevelNodes: Node[] = [
532+
...ast.body,
533+
...Array.from(inSourceBlocks).flatMap((block) => block.body as Node[]),
534+
]
535+
for (const node of topLevelNodes) {
536+
hoistedNodes.delete(node as any)
537+
if (node.type === 'ExpressionStatement') {
538+
hoistedNodes.delete(node.expression as any)
515539
}
540+
}
516541

517-
if (hoistedNodes.size) {
518-
const locations = createIndexLocationsMap(code)
519-
const map = options.getMap && new TraceMap(options.getMap() as any)
520-
const plural = hoistedNodes.size > 1
521-
const message = [
522-
`${hoistedNodes.size} call${plural ? 's' : ''} in "${relative(options.root || process.cwd(), id)}" ${plural ? 'were' : 'was'} defined outside of the module's top level scope:`,
523-
'',
524-
...Array.from(hoistedNodes, (invalidNode) => {
525-
const currentLocation = locations.get(invalidNode.start)
526-
const originalLocation =
527-
map && currentLocation && originalPositionFor(map, currentLocation)
528-
const location =
529-
originalLocation?.column != null && originalLocation?.line != null
530-
? ` at ${relative(options.root || process.cwd(), id)}:${originalLocation.line}:${originalLocation.column + 1}`
531-
: ''
532-
return `- ${getNodeName(getNodeCall(invalidNode))}${location}`
533-
}),
534-
'',
535-
`Although ${plural ? 'they appear nested, they' : 'it appears nested, it'} will be hoisted and executed before anything in this file. Move ${plural ? 'them' : 'it'} to the top level to reflect ${plural ? 'their' : 'its'} actual execution order.`,
536-
'See: https://vitest.dev/guide/mocking/modules#how-it-works',
537-
].join('\n')
538-
throw new Error(message)
539-
}
542+
if (hoistedNodes.size) {
543+
const locations = createIndexLocationsMap(code)
544+
const map = options.getMap && new TraceMap(options.getMap() as any)
545+
const plural = hoistedNodes.size > 1
546+
const topLevel = inSourceBlocks.size
547+
? 'the top level of the `if (import.meta.vitest)` block'
548+
: 'the top level'
549+
const message = [
550+
`${hoistedNodes.size} call${plural ? 's' : ''} in "${relative(options.root || process.cwd(), id)}" ${plural ? 'were' : 'was'} defined outside of the module's top level scope:`,
551+
'',
552+
...Array.from(hoistedNodes, (invalidNode) => {
553+
const currentLocation = locations.get(invalidNode.start)
554+
const originalLocation = map && currentLocation && originalPositionFor(map, currentLocation)
555+
const location =
556+
originalLocation?.column != null && originalLocation?.line != null
557+
? ` at ${relative(options.root || process.cwd(), id)}:${originalLocation.line}:${originalLocation.column + 1}`
558+
: ''
559+
return `- ${getNodeName(getNodeCall(invalidNode))}${location}`
560+
}),
561+
'',
562+
`Although ${plural ? 'they appear nested, they' : 'it appears nested, it'} will be hoisted and executed before anything in this file. Move ${plural ? 'them' : 'it'} to ${topLevel} to reflect ${plural ? 'their' : 'its'} actual execution order.`,
563+
'See: https://vitest.dev/guide/mocking/modules#how-it-works',
564+
].join('\n')
565+
throw new Error(message)
540566
}
541567

542568
// hoist vi.mock/vi.hoisted
543569
for (const node of arrayNodes) {
544570
const end = getNodeTail(code, node)
571+
const condition = hoistConditions.get(node)
572+
if (condition) {
573+
// `var` keeps the declared names visible outside of the `if` block
574+
if (node.type === 'VariableDeclaration') {
575+
s.update(node.start, node.start + node.kind.length, 'var')
576+
}
577+
s.prependRight(node.start, `if (${condition}) { `)
578+
s.appendLeft(node.end, ' }')
579+
}
545580
// don't hoist into itself if it's already at the top
546581
if (hoistIndex === end || hoistIndex === node.start) {
547582
hoistIndex = end

‎test/e2e/test/mocking.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,56 @@ test('mock works without loading original', () => {
371371
},
372372
)
373373

374+
// https://github.com/vitest-dev/vitest/issues/8653
375+
test.for(['node', 'playwright'])(
376+
'in-source vi.mock applies only when its file is the test file (%s)',
377+
async (mode) => {
378+
const { stderr, errorTree } = await runInlineTests(
379+
{
380+
'./src/name.js': `export const getName = () => 'original'`,
381+
'./src/b.js': `
382+
import { getName } from './name.js'
383+
export const greet = () => 'hello, ' + getName()
384+
385+
if (import.meta.vitest) {
386+
const { test, expect, vi } = import.meta.vitest
387+
const mocks = vi.hoisted(() => ({ getName: vi.fn(() => 'b') }))
388+
vi.mock('./name.js', () => mocks)
389+
test('b mocks name.js', () => {
390+
expect(greet()).toBe('hello, b')
391+
expect(mocks.getName).toHaveBeenCalledOnce()
392+
})
393+
}
394+
`,
395+
'./src/a.js': `
396+
import { greet } from './b.js'
397+
398+
if (import.meta.vitest) {
399+
const { test, expect, vi } = import.meta.vitest
400+
vi.mock('./name.js', () => ({ getName: () => 'a' }))
401+
test('imported b does not mock name.js', () => {
402+
expect(greet()).toBe('hello, a')
403+
})
404+
}
405+
`,
406+
},
407+
{ ...modeToConfig(mode), includeSource: ['src/*.js'] },
408+
)
409+
410+
expect(stderr).toBe('')
411+
expect(errorTree()).toMatchInlineSnapshot(`
412+
{
413+
"src/a.js": {
414+
"imported b does not mock name.js": "passed",
415+
},
416+
"src/b.js": {
417+
"b mocks name.js": "passed",
418+
},
419+
}
420+
`)
421+
},
422+
)
423+
374424
test('doMock/doUnmock ordering is preserved in resolveMocks', async () => {
375425
// This tests repeats doUnmock + doMock
376426
// vi.doUnmock('/mock-lib-0');

‎test/e2e/test/no-module-runner.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,42 @@ test('add is only once', () => {
449449
`)
450450
})
451451

452+
test('in-source vi.hoisted and vi.mock work', async () => {
453+
const { stderr, testTree } = await runNoViteModuleRunnerTests(
454+
{
455+
'name.js': `export const getName = () => 'original'`,
456+
'in-source.js': /* js */ `
457+
import { getName } from './name.js'
458+
459+
if (import.meta.vitest) {
460+
const { test, expect, vi } = import.meta.vitest
461+
const mocks = vi.hoisted(() => ({ getName: vi.fn(() => 'mocked') }))
462+
vi.mock('./name.js', () => mocks)
463+
test('mocks name.js', () => {
464+
expect(getName()).toBe('mocked')
465+
expect(mocks.getName).toHaveBeenCalledOnce()
466+
})
467+
}
468+
`,
469+
'package.json': JSON.stringify({
470+
name: '@test/native-in-source-mock',
471+
type: 'module',
472+
}),
473+
},
474+
{
475+
includeSource: ['./in-source.js'],
476+
},
477+
)
478+
expect(stderr).toBe('')
479+
expect(testTree()).toMatchInlineSnapshot(`
480+
{
481+
"in-source.js": {
482+
"mocks name.js": "passed",
483+
},
484+
}
485+
`)
486+
})
487+
452488
test('cannot import JS file without extension in ESM', async () => {
453489
const { stderr, root } = await runNoViteModuleRunnerTests({
454490
'add.js': /* js */ `

0 commit comments

Comments
 (0)