fix(testing): scope module mock restore snapshots

This commit is contained in:
YeonGyu-Kim
2026-05-30 20:24:20 +09:00
parent 475a12a08d
commit 81c36675fb
4 changed files with 169 additions and 27 deletions
@@ -0,0 +1,57 @@
/// <reference types="bun-types" />
import { describe, expect, mock, test } from "bun:test"
import { installModuleMockLifecycle } from "./module-mock-lifecycle"
describe("installModuleMockLifecycle inactive restore ownership", () => {
test("keeps unrelated re-applied persistent mocks when inactive restore caller is unresolved", () => {
// given
const events: string[] = []
let callerUrl = "file:///repo/tests/first.test.ts"
const mockApi = {
module: (specifier: string, factory: () => Record<string, unknown>) => {
events.push(`module:${specifier}:${String(factory().named)}`)
},
restore: mock(() => {
events.push("delegate:restore")
}),
}
const { beginTestMockTracking, endTestMockTracking } = installModuleMockLifecycle(mockApi, {
getCallerStack: () => `Error\n at ${callerUrl}:5:1`,
getCallerUrl: () => callerUrl,
trackOnlyDuringActiveTest: true,
isPersistentModuleMockOwner: () => true,
resolveSpecifier: (specifier, ownerUrl) => `resolved:${ownerUrl}:${specifier}`,
loadOriginalModule: () => ({ ok: true, value: { named: "original" } }),
})
mockApi.module("./first", () => ({ named: "first mock" }))
callerUrl = "file:///repo/tests/second.test.ts"
mockApi.module("./second", () => ({ named: "second mock" }))
callerUrl = "file:///repo/tests/first.test.ts"
beginTestMockTracking()
mockApi.restore()
endTestMockTracking()
// when
callerUrl = "file:///repo/testing/unknown-owner.ts"
mockApi.restore()
// then
expect(events).toEqual([
"module:./first:first mock",
"module:./second:second mock",
"delegate:restore",
"module:./first:first mock",
"module:resolved:file:///repo/tests/first.test.ts:./first:first mock",
"module:./second:second mock",
"module:resolved:file:///repo/tests/second.test.ts:./second:second mock",
"delegate:restore",
"module:./first:original",
"module:resolved:file:///repo/tests/first.test.ts:./first:original",
"module:./second:second mock",
"module:resolved:file:///repo/tests/second.test.ts:./second:second mock",
])
})
})
@@ -311,7 +311,7 @@ describe("installModuleMockLifecycle active-test tracking", () => {
expect(loadOriginalModule).toHaveBeenCalledTimes(1) expect(loadOriginalModule).toHaveBeenCalledTimes(1)
}) })
test("reapplies the last active restore snapshot when a later inactive restore runs", () => { test("does not reapply the last active restore snapshot after tracking ends", () => {
// given // given
const events: string[] = [] const events: string[] = []
const mockApi = { const mockApi = {
@@ -345,8 +345,50 @@ describe("installModuleMockLifecycle active-test tracking", () => {
"module:./dependency:original", "module:./dependency:original",
"module:resolved:./dependency:original", "module:resolved:./dependency:original",
"delegate:restore", "delegate:restore",
])
})
test("does not replay a prior test snapshot during the next test cleanup", () => {
// given
const events: string[] = []
let callerUrl = "file:///repo/tests/first.test.ts"
const mockApi = {
module: (specifier: string, factory: () => Record<string, unknown>) => {
events.push(`module:${specifier}:${String(factory().named)}`)
},
restore: mock(() => {
events.push("delegate:restore")
}),
}
const { beginTestMockTracking, endTestMockTracking } = installModuleMockLifecycle(mockApi, {
getCallerStack: () => `Error\n at ${callerUrl}:5:1\n at cleanup (native:1:11)`,
getCallerUrl: () => callerUrl,
trackOnlyDuringActiveTest: true,
resolveSpecifier: (specifier) => `resolved:${specifier}`,
loadOriginalModule: () => ({ ok: true, value: { named: "original" } }),
})
beginTestMockTracking()
mockApi.module("./dependency", () => ({ named: "mocked" }))
callerUrl = "file:///repo/testing/test-setup.ts"
mockApi.restore()
endTestMockTracking()
// when
callerUrl = "file:///repo/tests/second.test.ts"
beginTestMockTracking()
endTestMockTracking()
callerUrl = "file:///repo/testing/test-setup.ts"
mockApi.restore()
// then
expect(events).toEqual([
"module:./dependency:mocked",
"delegate:restore",
"module:./dependency:original", "module:./dependency:original",
"module:resolved:./dependency:original", "module:resolved:./dependency:original",
"delegate:restore",
]) ])
}) })
+35
View File
@@ -114,6 +114,41 @@ describe("installModuleMockLifecycle", () => {
]) ])
}) })
test("does not replay the last restore snapshot for a different restore caller", () => {
// given
const events: string[] = []
let callerUrl = "file:///repo/tests/first.test.ts"
const mockApi = {
module: (specifier: string, factory: () => Record<string, unknown>) => {
events.push(`module:${specifier}:${String(factory().named)}`)
},
restore: mock(() => {
events.push("delegate:restore")
}),
}
installModuleMockLifecycle(mockApi, {
getCallerUrl: () => callerUrl,
resolveSpecifier: (specifier) => `resolved:${specifier}`,
loadOriginalModule: () => ({ ok: true, value: { named: "original" } }),
})
// when
mockApi.module("./dependency", () => ({ named: "mocked" }))
mockApi.restore()
callerUrl = "file:///repo/tests/second.test.ts"
mockApi.restore()
// then
expect(events).toEqual([
"module:./dependency:mocked",
"delegate:restore",
"module:./dependency:original",
"module:resolved:./dependency:original",
"delegate:restore",
])
})
test("captures the original module only once per resolved specifier", () => { test("captures the original module only once per resolved specifier", () => {
// given // given
let loadCount = 0 let loadCount = 0
+34 -26
View File
@@ -96,9 +96,11 @@ export function installModuleMockLifecycle(
const snapshots = new Map<string, ModuleSnapshot>() const snapshots = new Map<string, ModuleSnapshot>()
const persistentSnapshots = new Map<string, Map<string, PersistentModuleSnapshot>>() const persistentSnapshots = new Map<string, Map<string, PersistentModuleSnapshot>>()
let lastRestoredSnapshots: ModuleSnapshot[] = [] let lastRestoredSnapshots: ModuleSnapshot[] = []
let lastRestoredSnapshotOwnerUrl: string | null = null
let isActiveTest = !options.trackOnlyDuringActiveTest let isActiveTest = !options.trackOnlyDuringActiveTest
let hasStartedTest = false let hasStartedTest = false
let activeTestOwnerUrl: string | null = null let activeTestOwnerUrl: string | null = null
let lastActiveTestOwnerUrl: string | null = null
const delegateModule = mockApi.module.bind(mockApi) const delegateModule = mockApi.module.bind(mockApi)
const delegateRestore = mockApi.restore.bind(mockApi) const delegateRestore = mockApi.restore.bind(mockApi)
const getCallerStack = options.getCallerStack ?? defaultGetCallerStack const getCallerStack = options.getCallerStack ?? defaultGetCallerStack
@@ -110,8 +112,13 @@ export function installModuleMockLifecycle(
return options.getCallerUrl?.() ?? resolveCallerUrlFromStack(callerStack) return options.getCallerUrl?.() ?? resolveCallerUrlFromStack(callerStack)
} }
function restoreModuleMocksForRestoreCall(): void { function restoreModuleMocksForRestoreCall(callerUrl: string): void {
const snapshotsToRestore = snapshots.size > 0 ? Array.from(snapshots.values()) : lastRestoredSnapshots const snapshotsToRestore =
snapshots.size > 0
? Array.from(snapshots.values())
: callerUrl === lastRestoredSnapshotOwnerUrl
? lastRestoredSnapshots
: []
for (const snapshot of snapshotsToRestore) { for (const snapshot of snapshotsToRestore) {
for (const restoreSpecifier of snapshot.restoreSpecifiers) { for (const restoreSpecifier of snapshot.restoreSpecifiers) {
@@ -121,6 +128,7 @@ export function installModuleMockLifecycle(
if (snapshots.size > 0) { if (snapshots.size > 0) {
lastRestoredSnapshots = snapshotsToRestore lastRestoredSnapshots = snapshotsToRestore
lastRestoredSnapshotOwnerUrl = callerUrl
snapshots.clear() snapshots.clear()
} }
} }
@@ -153,41 +161,37 @@ export function installModuleMockLifecycle(
function clearPersistentModuleMocksForOwner( function clearPersistentModuleMocksForOwner(
ownerUrl: string, ownerUrl: string,
restoreOriginals: boolean, restoreOriginals: boolean,
forceRestoreOriginals = false,
): void { ): void {
let clearedOwnerSnapshot = false
for (const [resolvedSpecifier, snapshotsByOwner] of persistentSnapshots) { for (const [resolvedSpecifier, snapshotsByOwner] of persistentSnapshots) {
const snapshot = snapshotsByOwner.get(ownerUrl) const snapshot = snapshotsByOwner.get(ownerUrl)
if (snapshot) { if (snapshot) {
if (restoreOriginals && (forceRestoreOriginals || snapshot.reappliedDuringActiveRestore)) { if (restoreOriginals && snapshot.reappliedDuringActiveRestore) {
restorePersistentOriginals(snapshot, ownerUrl) restorePersistentOriginals(snapshot, ownerUrl)
} }
snapshotsByOwner.delete(ownerUrl) snapshotsByOwner.delete(ownerUrl)
clearedOwnerSnapshot = true
} }
if (snapshotsByOwner.size === 0) { if (snapshotsByOwner.size === 0) {
persistentSnapshots.delete(resolvedSpecifier) persistentSnapshots.delete(resolvedSpecifier)
} }
} }
}
if (clearedOwnerSnapshot || !restoreOriginals) { function hasPersistentModuleMockOwner(ownerUrl: string): boolean {
return for (const snapshotsByOwner of persistentSnapshots.values()) {
} if (snapshotsByOwner.has(ownerUrl)) {
return true
for (const [resolvedSpecifier, snapshotsByOwner] of persistentSnapshots) {
for (const [snapshotOwnerUrl, snapshot] of snapshotsByOwner) {
if (!snapshot.reappliedDuringActiveRestore) {
continue
}
restorePersistentOriginals(snapshot, snapshotOwnerUrl)
snapshotsByOwner.delete(snapshotOwnerUrl)
}
if (snapshotsByOwner.size === 0) {
persistentSnapshots.delete(resolvedSpecifier)
} }
} }
return false
}
function resolveInactiveRestoreOwner(callerUrl: string): string {
if (hasPersistentModuleMockOwner(callerUrl)) {
return callerUrl
}
return lastActiveTestOwnerUrl ?? callerUrl
} }
function restoreModuleMocks(): void { function restoreModuleMocks(): void {
@@ -195,7 +199,7 @@ export function installModuleMockLifecycle(
return return
} }
restoreModuleMocksForRestoreCall() restoreModuleMocksForRestoreCall(getCallerUrl(getCallerStack()))
} }
function beginTestMockTracking(): void { function beginTestMockTracking(): void {
@@ -203,11 +207,14 @@ export function installModuleMockLifecycle(
isActiveTest = true isActiveTest = true
const callerStack = getCallerStack() const callerStack = getCallerStack()
activeTestOwnerUrl = getCallerUrl(callerStack) activeTestOwnerUrl = getCallerUrl(callerStack)
lastActiveTestOwnerUrl = activeTestOwnerUrl
} }
function endTestMockTracking(): void { function endTestMockTracking(): void {
isActiveTest = !options.trackOnlyDuringActiveTest isActiveTest = !options.trackOnlyDuringActiveTest
activeTestOwnerUrl = null activeTestOwnerUrl = null
lastRestoredSnapshots = []
lastRestoredSnapshotOwnerUrl = null
} }
mockApi.module = (specifier: string, factory: MockModuleFactory): unknown => { mockApi.module = (specifier: string, factory: MockModuleFactory): unknown => {
@@ -269,15 +276,16 @@ export function installModuleMockLifecycle(
const callerUrl = getCallerUrl(callerStack) const callerUrl = getCallerUrl(callerStack)
const result = delegateRestore() const result = delegateRestore()
if (!isActiveTest) { if (!isActiveTest) {
restoreModuleMocksForRestoreCall() restoreModuleMocksForRestoreCall(callerUrl)
snapshots.clear() snapshots.clear()
lastRestoredSnapshots = [] lastRestoredSnapshots = []
clearPersistentModuleMocksForOwner(callerUrl, hasStartedTest) lastRestoredSnapshotOwnerUrl = null
clearPersistentModuleMocksForOwner(resolveInactiveRestoreOwner(callerUrl), hasStartedTest)
restorePersistentModuleMocksForRestoreCall(false) restorePersistentModuleMocksForRestoreCall(false)
return result return result
} }
restoreModuleMocksForRestoreCall() restoreModuleMocksForRestoreCall(callerUrl)
restorePersistentModuleMocksForRestoreCall(true) restorePersistentModuleMocksForRestoreCall(true)
return result return result
} }