加了 PWA 之後,Playwright 的 mock 全部失效

一個沒有動任何測試、任何元件的 commit,讓 E2E 開始紅。root cause 是 service worker 接管之後,page.route 就攔不到請求了。這篇拆解為什麼只有 reload 之後才壞、為什麼失敗訊息 完全指向錯的方向,以及修法本身要付的代價。

E2E 開始紅。而那次 commit 沒有動任何 spec、沒有動任何元件,只加了 PWA 設定。

git diff 看不出任何關聯。這是最難查的一種紅燈——因為關聯不在 diff 裡。

症狀

spec 在 page.reload() 之後的那一步等不到畫面,而且錯誤是 element(s) not found,不是 assertion 值不符:

Error: element(s) not found
Call log:
  - Expect "toBeVisible" with timeout 5000ms
  - waiting for getByText('目前沒有輪到你判的項目。')

同一個檔案裡 reload() 之前的斷言全部通過。reload() 緊接著的那一條——讀 client 端狀態的那個——也通過。

只有再往後、需要打 API 的那一步掛掉。

這個「部分通過」的形狀提供了關鍵線索,但它很容易被讀成隨機的 flaky。

根因

page.route() 攔不到 service worker 自己發出的請求。

Playwright 的 page.route 掛在 page 的網路層。service worker 接管 client 之後:

  1. 頁面的 fetch 先進 SW 的 fetch event
  2. SW 對該 route 若使用 NetworkOnly(或任何 handler),它會由 SW 自己重新發出請求
  3. 那是 service-worker-initiated request,不經過 page 的攔截點
  4. 請求打到真的 server,mock 完全不生效

為什麼只有 reload 之後才壞

這是時序問題。

PWA 整合套件在 registerType: 'autoUpdate' 且使用預設外掛設定時,會自動設 clientsClaim: true 與 skipWaiting: true。於是:

時間點page 狀態mock
第一次 goto()還沒被 SW controlled正常
SW 註冊 → activate → claim過渡—
reload() 之後controlled全部失效

所以 reload 之前的斷言全綠,reload 之後只要碰 API 就掛。而讀 client 端狀態的那條不碰 API,所以它也綠——這條「例外」正是拼圖的最後一塊。

為什麼沒有任何一層接住它

這件事本身就發生在測試裡。 沒有「另一層測試」能接住它。

而其他幾個因素讓它更難查:

  • 失敗訊息完全指向錯的方向。 element(s) not found 講的是 UI,完全沒提到 service worker、快取或網路。照著訊息查會去查元件與 selector,而那兩處都沒有改
  • 手動驗 PWA 時一切正常。 production 沒有 mock,SW 的 NetworkOnly 就只是原樣轉發。「production 是對的」與「測試會壞」同時成立,而只驗前者不會發現後者
  • 這個坑會遲到。 加 PWA 的那個 commit 未必馬上跑到會受影響的那組 E2E。等它紅的時候,作者已經離開那個 context 了

第二點值得單獨強調。這是一個很常見的盲區形狀:

一個改動讓 production 正確、同時讓測試環境失效。而驗收流程只驗 production 正確。

這種改動會通過所有人工驗收,然後在 CI 上以一個看似無關的形式爆炸。

判定

風險條件是三件事同時成立:

  1. 專案有 service worker
  2. E2E 用 page.route() 餵資料
  3. Playwright config 沒有對 serviceWorkers 表態

第三點是重點。顯式寫了 serviceWorkers: 'allow' 代表作者知道並接受這個行為,那不是風險——沒有表態才是。

sw=$(rg -l --glob '!node_modules' 'vite-plugin-pwa|serviceWorker\.register' . | wc -l)
pr=$(rg -l --glob '!node_modules' 'page\.route\(' . | wc -l)
opt=$(rg -l --glob '!node_modules' "serviceWorkers:\s*'(block|allow)'" . | wc -l)
[ "$sw" -gt 0 ] && [ "$pr" -gt 0 ] && [ "$opt" -eq 0 ] && echo "AT RISK"

兩個條件要一起看,交集才是風險。 單獨「有 SW」或單獨「有 mock」都完全沒問題。

這也帶出一個規劃上的提醒:目前沒有 SW、但 mock 依賴很深的專案,是未來最痛的那些。加 PWA 的那一天,所有既有的 E2E 會一起紅,而 root cause 與那次改動看起來毫無關係。

修法

在 Playwright config 的 use 區塊表態:

use: {
  baseURL: '…',
  trace: 'retain-on-failure',
  serviceWorkers: 'block',
},

serviceWorkers: 'block' 讓 context 完全不註冊 SW,page 永遠是 uncontrolled,page.route 回到加 PWA 之前的行為。

我們實測的結果是 2 failed 變成 28 passed,spec 一個字都沒改。

但這是有代價的,要一起決定

擋掉 SW 之後,E2E 就不再跑在 production 的請求路徑上,而 SW 本身零覆蓋。

要驗 SW 的行為必須另外寫一組不 mock 的 spec(serviceWorkers: 'allow' 加上真的 server)。不要指望原本那組 mock spec 順便驗到——它們現在明確地跑在一個沒有 SW 的世界裡。

所以正確的收尾是兩件事:

  1. 主要的 E2E 加 serviceWorkers: 'block',換回可預測的 mock
  2. 另開一組小的 SW spec,明確驗離線行為與快取策略

只做第一件事會得到綠燈,但你的 PWA 從此沒有任何自動化覆蓋。這是一個必須被明確決定的取捨,不是一個可以順手跳過的細節。

不要走的那條路

不要為了讓測試通過,改成讓 SW 對測試環境 passthrough。

那等於在 production code 裡開一條只為測試存在的分支,而它會在真實使用者身上生效。測試環境的問題要在測試設定裡解決,不要滲進產品程式碼。

這條線值得畫得很硬:production code 裡任何「如果是測試就……」的分支,都是把測試的問題轉嫁給使用者。

收束

這個坑的教訓不在 Playwright,也不在 PWA,而在兩層之間的假設。

page.route 的心智模型是「攔截這一頁的所有請求」。實際上它攔截的是「這一頁的網路層發出的請求」,而 service worker 是另一個發出請求的主體。兩個模型在沒有 SW 的世界裡完全等價,所以那個差別在你裝 PWA 之前不存在。

一般化之後是:任何在你與網路之間插入一層的東西,都會讓「攔在你這一側」的工具失去覆蓋範圍。 service worker 是一個,proxy、CDN、mock server、瀏覽器擴充套件都是。

裝上這類中間層的時候,值得列一次清單:現在有哪些東西是掛在中間層的錯誤那一側的?