問題
一支測試單獨跑會過,跟其他測試一起跑就失敗(或更糟:一起跑會過,但它其實什麼都沒驗到)。兩個獨立的來源,都跟「測試之間共用了狀態」有關。
來源一:HTTP client 的快取。 產品碼的 httpClient.get 有 30 秒 TTL 的快取。前一支測試打過 /todos 拿到成功回應,後一支測試把端點換成回 500,請求根本不會發出去——直接回快取裡的成功結果。於是:
測試:「API 失敗時要顯示錯誤訊息」
斷言:expect(store.error).toBeTruthy()
結果:等到 1000ms 超時,error 一直是 null追了兩輪才發現不是元件的問題,是請求沒發出去。
來源二:mock adapter 的註冊順序。 axios-mock-adapter 對同一個 matcher 重複註冊時,先註冊的先匹配。在 beforeEach 註冊一組正常回應,然後在個別測試裡再註冊一次想覆寫掉:
// beforeEach 裡
mock.onGet(/\/todos$/).reply(200, successEnvelope);
// 某個測試裡,想改成失敗
mock.onGet(/\/todos$/).reply(500, ...); // ← 沒有生效,前一個先匹配這個更陰險:測試會通過,但驗到的是成功路徑,不是它宣稱要測的失敗路徑。
根因
兩者都是「測試共用了一份跨測試存活的狀態」:
- 快取是模組層級的
Map,vi.restoreAllMocks()不會碰它 - mock handler 是 adapter 實例上的陣列,
mockClear()那類 API 也不會碰它
共通點是這些狀態不屬於任何一個測試,但每個測試都會讀它。而且失敗的樣子不像狀態污染——像元件有 bug、像斷言寫錯。
解法
beforeEach(() => {
// GET 有快取,不清的話後面的測試會吃到前面測試的成功結果
clearCache();
useTodoStore.getState().reset();
mock = new MockAdapter(httpClient);
registerDefaultHandlers();
});要在個別測試覆寫端點時,先清掉既有的 handler:
it('API 失敗時顯示錯誤訊息', async () => {
mock.resetHandlers(); // ← 沒有這行,下面那個註冊不會生效
mock.onGet(/\/todos$/).reply(500, ...);
...
});若該測試需要先成功載入、之後才失敗(例如「詳細頁載好之後刪除失敗」),把 resetHandlers() 放在載入完成之後:
render(<TodoDetailPage id="1" />);
await waitFor(() => expect(screen.getByText('學習 Astro 5')).toBeTruthy());
// 詳細資料載入後才換掉刪除端點,避免蓋到上面的 GET
mock.resetHandlers();
mock.onDelete(/\/todos\/\w+$/).reply(500, ...);為什麼這樣解
清快取放在 beforeEach 而不是「需要的那幾支測試裡」,是因為污染的方向是單向的:任何一支打過 API 的測試都會影響它之後的所有測試。逐支加等於要記得哪些會互相影響,那不可能維持。
不改產品碼把快取關掉(例如加 disableCacheInTests 之類的旗標)——那會讓測試跑在跟正式環境不同的路徑上,快取本身的行為就沒人驗了。
怎麼預防再次發生
- 產品碼裡任何模組層級的可變狀態,測試都要有對應的清除。 清單:快取、佇列、註冊表、歷史記錄、單例。這些都不會被
vi.restoreAllMocks()清掉。 - 測試單獨跑會過、一起跑會失敗 → 先查跨測試狀態,不要先查元件。 反過來也要警覺:一起跑會過但單獨跑失敗,通常是依賴了前面測試留下的狀態。
- mock 覆寫要驗證它真的生效。 最簡單的辦法是斷言請求次數(
mock.history.get.length),而不是只斷言結果——結果可能來自快取或前一個 handler。 - 「覆寫」語意的 API 要查它是覆蓋還是追加。
axios-mock-adapter是追加;很多人以為是覆蓋。
相關
- 同型的假通過:測試名說「驗證 X」但實際 assert 的是別的東西。這裡是 assert 對了,但受測系統根本沒被觸發。
- 另一類相近的錯覺:守門機制(例如覆蓋率門檻)看起來有開、實際上沒生效。測試沒驗到、守門沒擋到,兩者都是「綠燈但沒意義」。