fix: 修复 setupAxiosMock 多测试文件并发时 mock 丢失的问题

mock.module('axios', ...) 是 process-global last-write-wins,多个测试文件
各自注册时只有最后一个 handle 的闭包被保留,导致前面的测试 stub 不生效。
改为全局单例注册,所有 handle 共享一个 mock.module,路由器运行时扫描
活跃 handle 分派请求。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-10 12:12:25 +08:00
parent 8fccd323a8
commit 80d4e095fd

View File

@@ -33,6 +33,7 @@
* objects with `isAxiosError: true`, set `axiosHandle.stubs.isAxiosError` — * objects with `isAxiosError: true`, set `axiosHandle.stubs.isAxiosError` —
* otherwise the real axios's predicate is used. * otherwise the real axios's predicate is used.
*/ */
import { mock } from 'bun:test' import { mock } from 'bun:test'
// Test stubs come in many shapes — `(url: string) => Promise<...>`, etc. — // Test stubs come in many shapes — `(url: string) => Promise<...>`, etc. —
@@ -63,78 +64,104 @@ export type AxiosMockHandle = {
stubs: AxiosMethodStubs stubs: AxiosMethodStubs
} }
// Global registry — all handles share one mock.module registration.
// The router scans handles in reverse order (most-recently activated first)
// to find one with `useStubs === true`.
let handles: AxiosMockHandle[] = []
let moduleRegistered = false
/** /**
* Register a process-global mock for `axios` that spreads the real module and * Register a process-global mock for `axios` that spreads the real module and
* gates each method behind a per-suite flag. Call once at the top of a test * gates each method behind a per-suite flag. Call once at the top of a test
* file (outside `describe`). Returns a handle whose `.useStubs` and `.stubs` * file (outside `describe`). Returns a handle whose `.useStubs` and `.stubs`
* fields the suite controls in beforeAll/afterAll. * fields the suite controls in beforeAll/afterAll.
*
* Multiple test files can call this safely — the `mock.module` is registered
* only once, and each handle is independent.
*/ */
export function setupAxiosMock(): AxiosMockHandle { export function setupAxiosMock(): AxiosMockHandle {
const handle: AxiosMockHandle = { useStubs: false, stubs: {} } const handle: AxiosMockHandle = { useStubs: false, stubs: {} }
handles.push(handle)
mock.module('axios', () => { if (!moduleRegistered) {
// Pull the REAL module synchronously inside the factory. Top-level moduleRegistered = true
// `await import('axios')` would resolve through the mock and recurse.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const real = require('axios') as Record<string, unknown>
const realDefault = ((real.default as
| Record<string, unknown>
| undefined) ?? real) as Record<string, unknown>
const route = (method: keyof AxiosMethodStubs): AnyFn => { mock.module('axios', () => {
const realFn = realDefault[method] as AnyFn | undefined // Pull the REAL module synchronously inside the factory. Top-level
return (...args: unknown[]) => { // `await import('axios')` would resolve through the mock and recurse.
if (handle.useStubs) { // eslint-disable-next-line @typescript-eslint/no-require-imports
const stub = handle.stubs[method] as AnyFn | undefined const real = require('axios') as Record<string, unknown>
if (stub) return stub(...args) const realDefault = ((real.default as
| Record<string, unknown>
| undefined) ?? real) as Record<string, unknown>
const route = (method: keyof AxiosMethodStubs): AnyFn => {
const realFn = realDefault[method] as AnyFn | undefined
return (...args: unknown[]) => {
// Scan from the end so the most recently activated handle wins.
for (let i = handles.length - 1; i >= 0; i--) {
const h = handles[i]
if (h.useStubs) {
const stub = h.stubs[method] as AnyFn | undefined
if (stub) return stub(...args)
// If the handle is active but has no stub for this method,
// fall through to the next active handle (or real axios).
}
}
if (typeof realFn === 'function') return realFn(...args)
throw new Error(`axios.${method} is not available on real axios`)
} }
if (typeof realFn === 'function') return realFn(...args)
throw new Error(`axios.${method} is not available on real axios`)
} }
}
const verbs: (keyof AxiosMethodStubs)[] = [ const verbs: (keyof AxiosMethodStubs)[] = [
'get', 'get',
'post', 'post',
'put', 'put',
'patch', 'patch',
'delete', 'delete',
'head', 'head',
'options', 'options',
'request', 'request',
'create', 'create',
] ]
const routedDefault: Record<string, unknown> = { ...realDefault } const routedDefault: Record<string, unknown> = { ...realDefault }
for (const v of verbs) { for (const v of verbs) {
routedDefault[v] = route(v) routedDefault[v] = route(v)
}
routedDefault.isAxiosError = (e: unknown) => {
if (handle.useStubs && handle.stubs.isAxiosError) {
return handle.stubs.isAxiosError(e)
} }
const realPredicate = realDefault.isAxiosError as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
}
routedDefault.isCancel = (e: unknown) => {
if (handle.useStubs && handle.stubs.isCancel) {
return handle.stubs.isCancel(e)
}
const realPredicate = realDefault.isCancel as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
}
return { routedDefault.isAxiosError = (e: unknown) => {
...real, for (let i = handles.length - 1; i >= 0; i--) {
...routedDefault, const h = handles[i]
default: routedDefault, if (h.useStubs && h.stubs.isAxiosError) {
} return h.stubs.isAxiosError(e)
}) }
}
const realPredicate = realDefault.isAxiosError as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
}
routedDefault.isCancel = (e: unknown) => {
for (let i = handles.length - 1; i >= 0; i--) {
const h = handles[i]
if (h.useStubs && h.stubs.isCancel) {
return h.stubs.isCancel(e)
}
}
const realPredicate = realDefault.isCancel as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
}
return {
...real,
...routedDefault,
default: routedDefault,
}
})
}
return handle return handle
} }