Appearance
第 28 天|測試在 CI 上失敗,我連續猜錯兩次原因
前言
我第一次把測試搬上 GitHub Actions 時,乾淨版連續失敗了兩次。報告和證據都在手上,我卻連續猜錯兩次原因。
這時候很容易想直接改測試,看看能不能讓它通過。不過,產品壞了、測試寫錯,甚至環境出問題,都會造成失敗。先查清楚,才知道該請誰處理。
你昨天跑的那一輪,結果不一定跟我當年一樣。配套程式已經包含今天會講到的修復,所以乾淨版多半會通過;含缺陷版的金額問題還在,應該會抓到失敗。也有可能某個附件沒下載到。今天就從你自己的交接摘要出發:手上有失敗就分析,全部通過就直接看我的例子,缺證據就先把缺口列出來。
我那兩次猜錯,剛好可以拿來說明:同一份紀錄,多看一點,結論真的會改變。
開始之前
先找出昨天存下的 handoff.md。它在第 27 天的證據目錄裡,例如 output/sessions/20260925_ci-e2e/run-執行編號-attempt-嘗試編號/handoff.md。打開後看兩個地方,決定今天走哪一條路:
| 摘要裡看到的 | 今天怎麼做 |
|---|---|
| 計數表有失敗 | 照下面的提示詞,分析你自己的失敗 |
| 兩邊都沒有失敗 | 不用分析。在摘要備註寫下「本輪無失敗」,直接往下看我的例子 |
| 「缺少的資料」有內容 | 先照實留著。分析只做到現有證據撐得住的地方,缺口交回來補 |
要分析的話,我們會交給 pipeline-triage,請它把可能來自同一原因的失敗分成一組,再用 failure-analysis 逐組分析。
為什麼先分組?假設準備資料那一步失敗,後面十支測試都跟著跑不下去,我們就不必分頭查十次。我當年那四筆失敗,最後也只分成兩組,但處理方式完全不同。
這一輪先查原因、決定交給誰。等證據夠了,再請對應的流程修,才不會查到一半,現場已經被自己改掉。
動手試試
不管你是接著昨天的對話,還是另開一段新對話,都把摘要路徑一起交給助手。助手不必記得昨天做過什麼,摘要裡已經寫了是哪一次執行、哪一次嘗試,以及證據放在哪裡:
text
請用 pipeline-triage 分析我昨天那輪 GitHub Actions 的失敗。
交接摘要:(貼上 handoff.md 的路徑)
先讀摘要,只用裡面的執行編號、嘗試編號和證據路徑,不要改拿其他執行的資料。
把相同原因的失敗分組,再用 failure-analysis 分析每一組。
對照失敗紀錄、畫面證據,以及同一工作中成功的測試。
摘要列為缺少的資料,照實標成缺件,不要自己補。
分析結果存到摘要所在的目錄,交回分類、依據的證據路徑與下一步交給誰。
這一輪先不修程式、不開單。交回後,先核對它引用的執行編號和嘗試編號,跟摘要是不是同一組。接著隨手點開一兩條證據路徑,確認它真的讀過內容,而不是只列出檔名。
如果乾淨版通過、只有含缺陷版失敗,你的結果多半會落在金額那一組。等一下看我的例子時,可以拿來對照。
跑完之後
你自己的分析先放著。接下來看我當年那一輪,它的乾淨版失敗,正好是你這次多半碰不到的情況。
那一輪的執行編號是 30710272941,附件已經到期,只剩文字紀錄和當時的分析檔,放在配套專案的 output/sessions/2026-08-02_ci-e2e-first-run/failure-analysis.yaml。下面的畫面和訊息都來自那時候的紀錄,不是你這輪的結果。
當時,我先看程式在哪裡停住。錯誤堆疊指向 gotoCart 裡的 waitForSelector,也就是前往購物車後,等不到要找的元素。
我很快就猜,是 addToCart 用 .catch 吞掉了等待失敗,商品根本沒加進去。這樣想似乎說得通,但還沒看畫面。
接著下載 error-context.md,看到的卻是這段:
text
heading "Performing security verification"
"This website uses a security service to protect against malicious bots"
Ray ID a2468b7c395bd62d
footer "Performance and Security by Cloudflare"原來根本還沒進到購物車,畫面停在 Cloudflare 的安全驗證。購物車元素都不在,當然等不到。於是我把原因改成 environment,也就是環境問題;再重跑失敗項目,還是同樣的畫面。
這時候可以確定症狀又出現了,但兩次都失敗,還不足以排除時好時壞。我們繼續往旁邊查:同一個工作裡,其他步驟跑得怎麼樣?這才發現:
text
gotoHome、商品頁、login.spec 都在同一個 runner、同一個出口 IP、
同一分鐘內成功載入,只有 gotoCart 那一步的 page.goto('/checkout') 被擋。首頁、商品頁和登入都正常,只有直接前往 /checkout 被擋。這就不太像整個執行環境都不能用了。我們因此把注意力放到測試怎麼進購物車,原始紀錄也改判成 test-defect,表示要往測試本身查。
不過,其他頁面正常,只能幫忙縮小範圍。這個判斷對不對,還要看後面怎麼改、重跑得到什麼結果。
我前後換了三次說法。把它們排在一起,比較看得出每一次是哪份證據讓判斷轉向,又還差什麼:
| 當時的假設 | 新看到的證據 | 能排除或縮小什麼 | 還不能證明什麼 | 下一步 |
|---|---|---|---|---|
addToCart 的 .catch 吞掉等待失敗,商品沒加進購物車 | error-context.md 顯示畫面停在 Cloudflare 安全驗證頁 | 排除「購物車是空的」:根本還沒進到購物車 | .catch 仍可能在其他情況藏起例外 | 改查畫面為什麼被擋;.catch 先記下,修測試時一起處理 |
environment:執行環境被擋,網站進不去 | 重跑失敗項目,還是同樣的驗證畫面 | 症狀會再出現,不是只發生一次 | 兩次都失敗,還不足以排除時好時壞;也不能證明整個環境都進不去 | 看同一個工作裡的其他步驟 |
test-defect:測試直接前往 /checkout,這條路被擋 | 同一個 runner、同一個出口 IP、同一分鐘內,首頁、商品頁、登入都成功,只有 page.goto('/checkout') 被擋 | 排除「整個執行環境不能用」,範圍縮到測試進購物車的方式 | 其他頁面成功只能縮小方向,不能單憑這點確定根因 | 交 test-heal 改進入方式,再用重跑驗收 |
最後一列的「還不能證明什麼」最容易被略過。其他頁面都正常,讓我們把方向轉到測試身上;但真正撐起這個判斷的,是改完之後的重跑結果。
原因查到這裡,就交給修測試的 test-heal。它把 gotoCart 改成點擊購物車入口 [data-test="nav-cart"],不再直接導向那個網址。
另外也拿掉 addToCart 裡的 .catch(() => {})。它不是這次被擋的直接原因,但會把前面的例外藏起來,等後面出錯才被發現,讓人更難追。
改完後,乾淨版連續三次通過,達到這輪設定的驗收門檻。接著再看含缺陷版,它的畫面原本就能正常載入,失敗訊息說的是另一件事:
text
Combination Pliers:14.15 × 1 應該等於列總計
Expected: 14.15
Received: 0這裡有商品、有金額,算出來的結果卻不對,而且和前面探索、獨立驗證的證據一致,因此分成 product-regression,交產品缺陷流程處理。
到這裡,四筆失敗就分清楚了:一組修測試,一組查產品。含缺陷版的金額問題還在,測試就應該繼續報錯。
既然中間改判過,我們也把原因留下來。下一位才看得出這個答案怎麼來。下面保留當時的紀錄格式:
yaml
nodeid: "e2e/cart-line-total.spec.ts > 每一列 Total 相加,應該等於頁尾的 Total [SUT=clean]"
classification: test-defect # 修正:初判 environment,錯的
original_classification: environment
confidence: high
basis: "……同一個 job 內部:gotoHome、商品頁、login.spec 都成功,只有 page.goto('/checkout') 被擋"
revision: "初判 environment 是錯的,理由是我只看到畫面被擋住就停手……"
fix_target: test-heal其中,original_classification 記下最初的分類,revision 說明為什麼改判。你接手時,就知道哪些方向已經查過,不用再從同一個猜測開始。
這份舊紀錄還有個地方要留意:test-defect 只說「測試有問題」,現行 failure-analysis 的細分類沒有這個值,不能直接照抄。
如果分類表找不到「導頁方式」這一類,就先填 unknown,在 basis 寫清楚查到了什麼、分類少了什麼,再交人確認。別為了填滿欄位,硬說是定位器壞掉,或另取一個下游看不懂的名字。
背後怎麼做
回頭看這個例子,就比較容易理解分類表的用途了。它幫我們決定往哪查、交給誰,但表上的現象仍要配合證據判斷:
| 分類欄位 | 要查什麼 | 下一步 |
|---|---|---|
locator | 定位方式是否失效 | 修測試的元素定位 |
wait-timing | 是否太早操作或讀值 | 修等待條件 |
test-data | 帳號或資料是否不符前提 | 準備、隔離測資 |
fixture-isolation | 前一支是否留下狀態 | 修前置準備與清理 |
assertion-mismatch | 實際結果與預期不同 | 先對照規格判斷 |
environment | 網路、憑證或服務是否異常 | 查環境,不急著改測試 |
product-regression | 產品行為是否違反預期 | 走產品缺陷流程 |
flaky | 同樣條件下是否有時通過、有時失敗 | 交明天的量測流程 |
cascade | 是否被前面的失敗拖累 | 修最早失敗的地方 |
unknown | 證據不足,或現有分類未涵蓋 | 明列缺口,補證或交人判斷 |
如果測的是程式介面,還會遇到回應結構改變、認證失效,或流量限制。對應名稱分別是 contract-drift、auth-expired、rate-limit。
看到 401、403、429 或 5xx,先把回應內容和前面的請求一起看。例如,取得憑證或建立測資就失敗,後面的錯誤可能只是被拖累,不能直接算在被測端點頭上。
其中,我們特別容易在「實際結果和預期不同」這裡修錯。這時先請 test-oracle 核對規格或使用者期待:規格改了、測試沒跟上,才改斷言;產品沒有照規格做,就走缺陷流程。第 25 天那支寫錯的測試,也是先找到依據,才知道要改哪一邊。
所以在 basis 裡,要寫出你看的是哪份證據。當然,有證據也不表示看夠了。我第二次猜錯時就有截圖,只是看完那張就停了;後來把同一工作裡成功的步驟也拿來比較,才發現原先的說法不夠完整。
今天學到什麼
我當年那四筆失敗,最後沒有全部丟給同一個人。查完證據,我們才分得出哪些要修測試、哪些要處理產品,也把改判的過程一起交給下一位。你自己這一輪也一樣:分析結果和昨天的摘要放在同一個目錄,下一位接手時,從同一份摘要就找得到。
不過,如果同一支測試這次失敗,下次卻通過,光看一兩次就更難判斷了。明天我們就多跑幾次,把「有時候會壞」記成具體數字。
作者備忘錄 — 正式出版前應移除
章節大綱
- 前言 — 接續乾淨版失敗,保留兩次錯判的故事。
- 開始之前 — 整批分組,再逐組分析。
- 動手試試 — 提供提示詞與歷史執行、證據路徑。
- 跑完之後 — 從猜測到對照,分開分析與後續修復。
- 背後怎麼做 — 細分類與判斷依據,說明舊欄位的差異。
- 今天學到什麼 — 接到不穩定測試量測。
圖片處理:舊圖寫「排除間歇,不是 flaky」,超出兩次重跑能支持的結論,因此本章移除該圖,保留完整文字歷程。
參考資料
- David J. Agans(2002)《Debugging: The 9 Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems》 - 第二條規則是 Quit Thinking and Look,正是本篇第一步猜錯、第二步拉證據的寫照
- Richard I. Cook(1998)《How Complex Systems Fail》 - 複雜系統的失效通常是多因的,單一根因往往是敘事上的簡化,所以分類要指得到證據
- John Allspaw(2012)《Blameless PostMortems and a Just Culture》,Etsy Code as Craft - 把判錯留在紀錄裡而不是抹掉,組織才學得到東西,對應
revision那一欄 - Gerald M. Weinberg(1985)《The Secrets of Consulting》 - 沒有證據支撐的因果推論有多容易成形,說明為什麼
basis是必填 - 本專案
skills/maintain/failure-analysis/SKILL.md- 十類分類表與 API 三類 - 本專案
output/sessions/2026-08-02_ci-e2e-first-run/failure-analysis.yaml- 含被推翻的初判 - 本專案
tests/broken/- 測試的錯的實測基準