❯❯ 別讓改個 CSS 就全紅!如何寫出不被 UI 樣式牽著走的 E2E 測試
📍 流水線位置|入口 → flow → 型別 → mock → 【測試】 → UI → vibe → 上線(合約的寫法)

寫過 E2E 測試的人多半有這個經驗:只是改個間距、調個圓角、換個 class 名稱,測試紅了一片,但業務邏輯明明一行都沒動。
這類問題通常是因為測試腳本直接斷言(Assert)具體的 DOM 階層結構或特定 CSS Class 名稱,導致測試與視覺表現過度耦合。當這種低品質的訊號頻繁出現,就像「狼來了」演變到最後,團隊看到紅燈的第一反應不再是「壞了什麼」,而是「又是哪條測試在亂報」。一旦自動化測試失去信任,它作為防禦機制的價值也就蕩然無存。
前一篇談過綠燈的邊界取決於「驗證範圍怎麼定義」;這一篇則聚焦在斷言的切入層級:如何讓測試精準鎖定領域不變量(Business Invariants),同時又不與視覺表現層緊密綁定。
跟 UI 長相過度耦合的測試,最明顯的特徵就是對「非業務關鍵」的細節下硬斷言:要求某個 Class 名稱必須存在、DOM 節點必須維持特定的階層結構,或是介面文案必須一字不差。

這種測試最大的問題是維護成本。隨手調個排版、改動非關鍵文案,測試就掛給你看;這時我就得放下手邊的工作去修正腳本,只為了讓它配合畫面現況。改久了,大家就會開始想繞過測試。
自動化測試的價值取決於訊號品質,並不是漂亮的覆蓋率數字。測試套件應該在業務邏輯出問題時發出可信的預警,而不是反過來成為介面演進的包袱。
我們拿「座位管理模組」的實體規格來做個對照。
在 Day 07 的 Flow 文件中,Invariant(領域不變量)明確定義:桌次的識別欄位(tableName)與正常席容量(capacity),必須可被使用者讀取。
※ 註:這裡的「正常席」指的是系統邏輯規則:兒童座椅屬於額外加位,不佔用預設的正常席容量。
對應到 E2E 測試腳本裡,實體斷言長這樣:
await page.getByLabel(/桌次名稱|名稱/).fill('主桌2')
// ...填寫容量與座標設定、架設 API Spy 監聽、發送表單並驗證 Payload
const entity = findEntity(page, /主桌2/)
await expect(entity).toBeVisible()
await expect(entity.getByText(/10/)).toBeVisible()
我們逐段拆解這幾行程式碼,看看它的斷言究竟落在哪個抽象層級:
1. getByLabel(/桌次名稱|名稱/):
使用表單欄位的「業務語意標籤」來定位元素,且正則刻意保持彈性(桌次名稱|名稱)。不論表單如何排版、欄位順序怎麼調整,測試腳本一概不干涉,它只要求這個業務語意能被找到且能順利填入。
2. findEntity(page, /主桌2/):
呼叫專案封裝好的 findEntity Helper 尋找指定名稱的實體。它底層會依序檢索三種 ARIA Role(row、article、listitem)。不管前端畫面要把資料呈現成表格橫列、卡片還是清單項目,測試腳本也都不關心,它只驗證這個領域實體在 DOM 裡存在且可見。
3. getByText(/10/):
直接驗證容量數值 10 能在該實體範圍內被讀取。至於字級大小、文字顏色或擺放位置,完全留給 UI 視覺層自由發揮。
除了 UI 層的語意驗證,這條測試還包含 API 網路層的第二重防線:架設 Spy 監聽 POST /tables 請求,精確驗證 Payload 裡的四項核心資料:名稱(主桌2)、容量(10)、座標(X=100、Y=200)。前端畫面呈現可以保有彈性,但網路層送出去的資料結構,必須跟合約一模一樣。
梳理一下這套斷言策略的「鬆與緊」:我的腳本只死守三項核心指標:實體存在性、資料可見性,以及 API 傳輸的精確性。只要這三者任何一項出錯,測試立刻紅燈;除此之外的視覺呈現與操作過程,則全面放寬。
收緊的部分,對應 Flow 文件裡的領域不變量(Invariant);而放寬的部分,正好是沒被凍結的視覺細節。測試腳本做的事,就是把 Flow 文件的文字約束翻譯成可執行的程式碼。
我用一張對映表,把座位模組的合約範圍與斷言策略完整攤開:
| Flow 領域不變量(Invariant) | Spec 實體斷言(Assertion) | 邊界控制策略 |
|---|---|---|
| 桌次識別欄位可被讀取 | findEntity(page, /主桌2/) 可見 |
語意鎖定: 收緊語意與實體存在感,不限呈現形式 |
| 正常席容量可被讀取 | 實體節點內 getByText(/10/) 可見 |
數值鎖定: 抓緊數值呈現,不限樣式與排版 |
| 新增桌次的操作流程可達 | getByLabel(/桌次名稱|名稱/) 可填寫且表單可提交 |
行為鎖定: 抓緊表單可操作性,不限欄位配置 |
| 資料寫入邏輯精確傳輸至後端 | API Spy 斷言:POST /tables Payload 包含名稱、容量、X、Y |
嚴格防守:網路合約毫無妥協空間 |
| 位置設定方式(解凍範圍) | 操作手法不作硬性斷言;座標數值在 Payload 嚴格驗證 | 鬆綁過程,鎖定結果:不限輸入方式(拖放/數字),只驗最終座標 |

最後一列是邊界劃分的經典範例:
Flow 文件把「位置設定方式(拖放畫布或數字輸入)」歸為彈性範疇,前端要怎麼實作互動都可以;但「最終座標數值」仍是不可妥協的合約,測試會在 API Payload 層精確斷言。
操作手法給予彈性,資料結果絕不商量。
至此,測試合約的撰寫規範與邊界防線已建構完畢。
接下來,該輪到自動化工具鏈上場了!看看 AI 怎麼對著這套凍結的測試防線,將 UI 元件與業務邏輯一步步實作至「全線綠燈」!
🎒 最小一步| 翻你專案的 E2E 測試,找出硬斷言具體 DOM 階層或 CSS Class 名稱的段落,改成用 ARIA Role、Label 或語意文字來斷言。
📎 本篇證據|test/e2e/specs/04-seating.spec.ts(斷言原始碼)對照spec/e2e-flows/04-seating.flow.md(Invariant 條款段落)