開發日誌 · 2026-07-21
今天在整理一批被 skip 掉的 UI 測試案例,想把「其實跑得過、只是當初懶得弄」的撿回來。結果撿回來的過程比我想的曲折,有兩個判斷是我很有把握、最後被真機打臉的。
一個會說謊的 helper:動作成功了,它卻噴 timeout
背景先講清楚,不然後面看不懂。我們的 E2E 是 Appium 跑跨平台(同一支 spec 同時測 iOS 和 Android),流程是多步驟表單:輸入 → 選一個管道 → 下一頁確認。中間有個共用的 helper,大概長這樣(示意):
// 動作 + 「確認真的過頁了」綁在同一個 helper 裡
async function goNext() {
await submitButton.click()
await channelAField.waitForDisplayed({ timeout: 15000 }) // 下一頁「一定會有」的欄位
}
那個 waitForDisplayed 是刻意加的——點完按鈕不代表真的過頁,所以用「下一頁一定看得到的欄位」當作導覽成功的訊號。主線上這樣沒問題,管道 A 的欄位一定在。
問題出在我要撿的這個案例,是「管道 A 被關掉、只剩管道 B」的變體。這時候點完按鈕,App 很正確地把我帶到了管道 B 的頁面——但那頁沒有 channelAField,永遠等不到。15 秒後 timeout,helper throw,測試紅。
我第一反應是這變體壞了,或是又 flaky。跑了好幾次探針去 dump 頁面,才看清楚:submitButton 點下去之後,頁面早就切到目的頁了,連管道 B 那張卡都渲染出來了。App 從頭到尾做得一模一樣對。錯的是我的 helper——它拿「管道 A 的欄位」當成功訊號,而這條路上根本不會有那個欄位,於是它把一個成功的動作,回報成 timeout 失敗。
假陰性。而且是最難抓的那種,因為畫面上看起來就是「卡住了」。
修法其實很小:把「做動作」和「斷言結果」拆開,別讓 helper 自己那個寫死的確認訊號去決定成敗。
await goNext().catch(() => {}) // 動作照做,timeout 就吞掉——它等的是錯的東西
await channelBField.waitForDisplayed() // 我真正在乎的,在目的頁上自己斷言
寫完覺得有點好笑。當初把確認訊號綁進 helper,是為了「更嚴謹」;結果這個嚴謹的前提(下一頁長什麼樣)只對主線成立,任何分支都會被它誤殺。一個動作 helper 一旦把某條路徑獨有的成功訊號寫死進去,它就只在那條路上說真話。
差點省下一次 build,結果被一次真跑打回原形
另一個案例是「金額超過額度時,確認頁要擋下來」。原本 skip 的理由寫的是「造不出這個狀態」,我本來很想推翻它。
稽核的時候我發現一件看起來很有用的事:試算頁和確認頁是兩個不同的後端回應在餵的,而且試算頁給的餘額上限,比確認頁給的額度上限還高。我腦子立刻轉:那我只要輸入一個「介於兩者之間」的金額,不就能過試算、然後剛好在確認頁踩到不足?不用加 mock、不用重 build,純靠一個金額就構造出隔離狀態。
我還特地去把兩個上限的值撈出來對,確認高低關係成立。很得意,想說這個 skip 撿定了。
然後我真的跑了一次。
金額卡在輸入頁,連試算都沒過去。原因很蠢也很合理:金額輸入框本身的上限,吃的也是確認頁那個較低的額度——不是我以為的、試算頁那個較高的餘額。所以「介於兩者之間」這個區間在 UI 上根本不存在,那個金額對輸入框來說就是超標,直接被擋在第一關。
我的推理沒錯,錯的是我腦子裡那張「哪個端點餵哪個畫面」的地圖漏了一塊:輸入框的驗證跟確認頁共用同一個上限。這種洞光看端點對應關係看不出來,只有真的把數字打進去、看它卡在哪一頁,才會現形。
所以那個 skip 是對的。要在這個流程裡隔離出「過試算但確認頁不足」,得動 App 那邊改用不同上限、或做有狀態的 mock,不是我這邊一個金額能凹出來的。我把理由從含糊的「造不出來」改成寫清楚為什麼造不出來——這樣下一個人就不用再賭一次。
有點不甘心,但比起把一個其實測不了的案例硬撐成綠的,寧可它誠實地紅著、或誠實地 skip 著。