From e9c3d72f0c8d6867de06a3df43ea28a05eeec444 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Thu, 15 Oct 2026 15:45:56 +0200 Subject: [PATCH] fix: release mocks of finished test files (#11607) --- .../runtime/moduleRunner/bareModuleMocker.ts | 6 + .../src/runtime/moduleRunner/moduleMocker.ts | 6 +- .../src/runtime/moduleRunner/moduleRunner.ts | 5 +- .../moduleRunner/startVitestModuleRunner.ts | 3 +- packages/vitest/src/runtime/runBaseTests.ts | 1 + packages/vitest/src/runtime/runVmTests.ts | 1 + test/e2e/test/mocking.test.ts | 266 ++++++++++++++++++ 7 files changed, 279 insertions(+), 9 deletions(-) diff --git a/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts index a128a7172..41eb2bc1a 100644 --- a/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts @@ -84,7 +84,13 @@ export class BareModuleMocker implements TestModuleMocker { } public reset(): void { + for (const registry of this.registries.values()) { + for (const url of registry.keys()) { + this.invalidateModuleById(registry.get(url)!.id) + } + } this.registries.clear() + BareModuleMocker.pendingIds = [] } protected invalidateModuleById(_id: string): void { diff --git a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts index fd78c682a..58bec21e6 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts @@ -64,10 +64,6 @@ export class VitestMocker extends BareModuleMocker { this.spyModule = await this.moduleRunner.import(spyModulePath) } - public reset(): void { - this.registries.clear() - } - protected invalidateModuleById(id: string): void { const mockId = this.getMockPath(id) const node = this.evaluatedModules.getModuleById(mockId) @@ -167,7 +163,7 @@ export class VitestMocker extends BareModuleMocker { } if (mock.type === 'manual') { - return this.callFunctionMock(id, url, mock) + return this.callFunctionMock(this.getMockPath(id), this.getMockPath(url), mock) } const node = await this.moduleRunner.fetchModule(mock.redirect) return this.moduleRunner.cachedRequest(mock.redirect, node, [importer], undefined, true) diff --git a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts index c2ea62b1d..93add1b99 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts @@ -1,4 +1,3 @@ -import type { MockedModule } from '@vitest/mocker' import type vm from 'node:vm' import type { EvaluatedModuleNode, EvaluatedModules, SSRImportMetadata } from 'vite/module-runner' import type { WorkerGlobalState } from '../../types/worker' @@ -173,7 +172,6 @@ export class VitestModuleRunner extends viteModuleRunner.ModuleRunner implements let mocked: any if (mod.meta && 'mockedModule' in mod.meta) { - const mockedModule = mod.meta.mockedModule as MockedModule const mockId = this.mocker.getMockPath(mod.id) const currentMock = this.mocker.getDependencyMock(mod.id) // bypass mock and force "importActual" behavior when: @@ -191,8 +189,9 @@ export class VitestModuleRunner extends viteModuleRunner.ModuleRunner implements const node = await this.fetchModule(injectQuery(url, '_vitest_original')) return this._cachedRequest(node.url, node, callstack, metadata) } + // mocked metadata comes from a manual or redirect mock and has no code to automock const isAutoMock = currentMock.type === 'automock' || currentMock.type === 'autospy' - if (isAutoMock && currentMock !== mockedModule) { + if (isAutoMock) { const freshNode = await this.fetchModule(injectQuery(url, '_vitest_original')) mocked = await this.mocker.requestWithMockedModule(url, freshNode, callstack, currentMock) } else { diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index e0ad218f2..933e626ab 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -172,7 +172,8 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi id: resolvedMock.id, url: resolvedMock.url, invalidate: false, - mockedModule: resolvedMock, + // the module node outlives the test file, so it must not keep the mock factory alive + mockedModule: true, } } } diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts index 0e9d67a38..fae775b5f 100644 --- a/packages/vitest/src/runtime/runBaseTests.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -105,6 +105,7 @@ export async function run( vi.resetConfig() // mocks should not affect different files vi.restoreAllMocks() + moduleRunner.mocker?.reset() } }) } finally { diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index 80b6ffe2a..264ebd6d3 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -143,6 +143,7 @@ export async function run( vi.resetConfig() // mocks should not affect different files vi.restoreAllMocks() + moduleRunner.mocker?.reset() } }) } finally { diff --git a/test/e2e/test/mocking.test.ts b/test/e2e/test/mocking.test.ts index 2784652ca..dafa638e5 100644 --- a/test/e2e/test/mocking.test.ts +++ b/test/e2e/test/mocking.test.ts @@ -726,3 +726,269 @@ test('automock mockReturnValue works', () => { } `) }) + +function trackScope(name: string) { + return ` +const scope = vi.hoisted(() => { + const scope = { name: '${name}' } + globalThis.scopes = [...(globalThis.scopes ?? []), new WeakRef(scope)] + return scope +})` +} + +test.for(['threads', 'forks'])( + 'isolate:false releases mocks of finished files (%s)', + async (pool) => { + const { stderr, errorTree } = await runInlineTests( + { + './dep-a.ts': `export const dep = () => 'real'`, + './dep-b.ts': `export const dep = () => 'real'`, + './dep-c.ts': `export const dep = () => 'real'`, + './dep-d.ts': `export const dep = () => 'real'`, + './dep-e.ts': `export const dep = () => 'real'`, + './dep-f.ts': `export const dep = () => 'real'`, + './a-factory.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep-a' +${trackScope('vi.mock factory')} +vi.mock('./dep-a', () => ({ dep: () => scope.name })) +test('mocked', () => { + expect(dep()).toBe('vi.mock factory') +}) + `, + './b-automock.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep-b' +${trackScope('automock')} +vi.mock('./dep-b') +test('mocked', () => { + vi.mocked(dep).mockImplementation(() => scope.name) + expect(dep()).toBe('automock') +}) + `, + './c-autospy.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep-c' +${trackScope('autospy')} +vi.mock('./dep-c', { spy: true }) +test('mocked', () => { + vi.mocked(dep).mockImplementation(() => scope.name) + expect(dep()).toBe('autospy') +}) + `, + './d-do-mock.test.ts': ` +import { expect, test, vi } from 'vitest' +${trackScope('vi.doMock factory')} +test('mocked', async () => { + vi.doMock('./dep-d', () => ({ dep: () => scope.name })) + const { dep } = await import('./dep-d') + expect(dep()).toBe('vi.doMock factory') +}) + `, + './e-import-mock.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep-e' +${trackScope('vi.importMock factory')} +vi.mock('./dep-e', () => ({ dep: () => scope.name })) +test('mocked', async () => { + const mocked = await vi.importMock('./dep-e') + expect(mocked.dep).toBe(dep) +}) + `, + './f-unused-do-mock.test.ts': ` +import { test, vi } from 'vitest' +${trackScope('unused vi.doMock factory')} +test('mock is never imported', () => { + vi.doMock('./dep-f', () => ({ dep: () => scope.name })) +}) + `, + './z-released.test.ts': ` +import v8 from 'node:v8' +import vm from 'node:vm' +import { expect, test } from 'vitest' + +v8.setFlagsFromString('--expose-gc') +const gc = vm.runInNewContext('gc') +v8.setFlagsFromString('--no-expose-gc') + +test('mock factories of previous files are released', async () => { + expect(globalThis.scopes).toHaveLength(6) + let retained = [] + for (let attempt = 0; attempt < 10; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + gc() + retained = globalThis.scopes.map((ref) => ref.deref()?.name).filter(Boolean) + if (!retained.length) { + break + } + } + expect(retained).toEqual([]) +}) + `, + }, + { + pool, + isolate: false, + sequence: { sequencer: StableTestFileOrderSorter }, + }, + ) + + expect(stderr).toBe('') + expect(errorTree()).toMatchInlineSnapshot(` + { + "a-factory.test.ts": { + "mocked": "passed", + }, + "b-automock.test.ts": { + "mocked": "passed", + }, + "c-autospy.test.ts": { + "mocked": "passed", + }, + "d-do-mock.test.ts": { + "mocked": "passed", + }, + "e-import-mock.test.ts": { + "mocked": "passed", + }, + "f-unused-do-mock.test.ts": { + "mock is never imported": "passed", + }, + "z-released.test.ts": { + "mock factories of previous files are released": "passed", + }, + } + `) + }, +) + +test.for(['forks', 'vmThreads'])('mocks of finished files do not apply (%s)', async (pool) => { + const realTest = ` +import { expect, test } from 'vitest' +import * as dep from './dep' +import * as other from './other' +test('real', () => { + expect(Object.keys(dep)).toEqual(['dep']) + expect(dep.dep()).toBe('real') + expect(other.other()).toBe('real other') +}) +` + const { stderr, errorTree } = await runInlineTests( + { + './dep.ts': `export const dep = () => 'real'`, + './other.ts': `export const other = () => 'real other'`, + './__mocks__/other.ts': `export const other = () => '__mocks__ other'`, + './a-factory.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep' +vi.mock('./dep', () => ({ dep: () => 'factory' })) +test('factory', () => { + expect(dep()).toBe('factory') +}) + `, + './b-real.test.ts': realTest, + './c-automock.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep' +vi.mock('./dep') +test('automock', () => { + expect(vi.isMockFunction(dep)).toBe(true) + expect(dep()).toBe(undefined) +}) + `, + './d-real.test.ts': realTest, + './e-autospy.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep' +vi.mock('./dep', { spy: true }) +test('autospy', () => { + expect(dep()).toBe('real') + expect(dep).toHaveBeenCalledTimes(1) +}) + `, + './f-import-original.test.ts': ` +import { expect, test, vi } from 'vitest' +import * as dep from './dep' +vi.mock(import('./dep'), async (importOriginal) => ({ ...await importOriginal(), extra: true })) +test('importOriginal', () => { + expect(dep).toMatchObject({ extra: true }) + expect(dep.dep()).toBe('real') +}) + `, + './g-redirect.test.ts': ` +import { expect, test, vi } from 'vitest' +import { other } from './other' +vi.mock('./other') +test('__mocks__', () => { + expect(other()).toBe('__mocks__ other') +}) + `, + './h-real.test.ts': realTest, + './i-unused-do-mock.test.ts': ` +import { test, vi } from 'vitest' +test('mock is never imported', () => { + vi.doMock('./dep', () => ({ dep: () => 'unused vi.doMock' })) +}) + `, + './j-real.test.ts': realTest, + './k-import-mock.test.ts': ` +import { expect, test, vi } from 'vitest' +import { dep } from './dep' +vi.mock('./dep', () => ({ dep: () => 'factory' })) +test('importMock', async () => { + const mocked = await vi.importMock('./dep') + expect(mocked.dep).toBe(dep) + expect(dep()).toBe('factory') +}) + `, + './l-real.test.ts': realTest, + }, + { + pool, + isolate: false, + sequence: { sequencer: StableTestFileOrderSorter }, + }, + ) + + expect(stderr).toBe('') + expect(errorTree()).toMatchInlineSnapshot(` + { + "a-factory.test.ts": { + "factory": "passed", + }, + "b-real.test.ts": { + "real": "passed", + }, + "c-automock.test.ts": { + "automock": "passed", + }, + "d-real.test.ts": { + "real": "passed", + }, + "e-autospy.test.ts": { + "autospy": "passed", + }, + "f-import-original.test.ts": { + "importOriginal": "passed", + }, + "g-redirect.test.ts": { + "__mocks__": "passed", + }, + "h-real.test.ts": { + "real": "passed", + }, + "i-unused-do-mock.test.ts": { + "mock is never imported": "passed", + }, + "j-real.test.ts": { + "real": "passed", + }, + "k-import-mock.test.ts": { + "importMock": "passed", + }, + "l-real.test.ts": { + "real": "passed", + }, + } + `) +}) -- 2.54.0