前言:frontend 修補的驗證最常被推給人手點
修了一個 frontend modal 的提交守門 bug,把改動推上 STG(staging 環境),下一步「驗證」往往就是寄訊息給 QA 或產品經理:「請開瀏覽器點點看,看 UI 守門有沒有生效。」
這個步驟在 CI 跑完單元測試後通常還是被當成人類的責任,原因是 E2E(end-to-end,端到端)環境難搭、寫一次 Playwright 腳本要花的時間比人手點還久、而且 UI 本來就會變動。
但隨著 LLM agent 配上瀏覽器自動化能力,這個 trade-off 已經悄悄反轉了。Bot 不只能省掉人手點擊的時間,更會做一件人類常常忘了做的事:在 UI 看似成功之後,去資料庫驗證資料真的寫對了。 這篇文章記錄一次真實的 STG 驗收,原本要驗 frontend 修補,卻意外揭發 backend 的 dual-write regression(雙寫機制失效)。
Playwright MCP 是什麼,為什麼是它打破僵局
先把兩個關鍵字釐清:
Playwright 是微軟釋出的瀏覽器自動化框架,原本是寫 JavaScript 或 Python 腳本控制 chromium。
MCP(Model Context Protocol) 是 Anthropic 推的開放協議,把外部工具用宣告式 schema 包成 LLM 可呼叫的 server,類似 LSP(Language Server Protocol)之於編輯器——讓不同 IDE 共用同一個語言分析後端。LLM 客戶端啟動時會跟 MCP server 握手取得可用 tool 清單,之後對話中就能像呼叫 function 一樣直接用。
把兩者合起來,Playwright MCP 就是把 chromium 包成 MCP server,LLM 透過 browser_navigate、browser_click、browser_snapshot、browser_evaluate 這幾個 tool 直接驅動瀏覽器。裝在 user scope 一次就好:
claude mcp add playwright -s user -- npx @playwright/mcp@latest
之後任何 Claude Code session 開場就能呼叫,第一次 navigate 才會下載 ~150 MB 的 chromium。
它真正改變遊戲規則的不是「能驅動瀏覽器」這件事——Playwright 早就能做到——而是 agent 能根據當下看到的頁面內容決定下一步。傳統 Playwright 腳本是寫死的:點按鈕 A、等 selector B 出現、斷言文字 C。Agent 拿到頁面 snapshot 後可以自己判斷:欄位該填什麼、要不要先 cancel 才能進下一步、看到非預期 modal 該怎麼繞。
寫一支健壯的 Playwright 腳本要半天,請 agent 跑同樣的場景不到五分鐘。
Reconnaissance-then-action:四個場景的設計
這次驗證涵蓋一個 modal 守門修補,總共四個場景:
| 場景 | 操作 | 預期 |
|---|---|---|
| 1. 守門 | 切換到「+ 新增類型」mode、輸入字串、不按確認,直接送出 | 主送出按鈕 disabled |
| 2. Happy path | 選預設類型、填日期、送出 | success toast、列表出現新項目 |
| 3. Twin modal | 另一個 modal 用相同 UX | 對稱守門 |
| 4. 中文錯誤 | 輸入會被 backend 拒絕的重複名稱 | toast 顯示中文訊息 |
驅動者 agent 採用 **reconnaissance-then-action(先偵察、後行動)**模式:每動一步就先 browser_snapshot 看當下 DOM 結構,根據 ref 找到正確的 element,再 browser_click。這個迴圈聽起來慢,實際上每一步只多一次 LLM 推理,比寫死的腳本更能適應 selector drift(CSS class hash 變動、元素層級重構導致寫死 selector 失效)。
關鍵技巧是混用兩個工具:
browser_snapshot拿的是 accessibility tree——瀏覽器為螢幕閱讀器建構出來的結構化 DOM,比原始 HTML 輕量、語意更清楚(headings、buttons、inputs 都標好角色)。快速但有時候少資訊。browser_evaluate直接執行任意 JS,可以看 disabled 屬性、titletooltip、computed style、甚至打 fetch。
場景 1 驗證守門時,光看 snapshot 知道按鈕有 disabled 標記還不夠——需要 browser_evaluate 去讀 title 屬性確認 tooltip 文字也是中文。
關鍵判斷:POST 200 真的代表寫對了嗎?
場景 2 是這次發現問題的引爆點。Agent 點完送出按鈕後:
- 出現確認 dialog → 點「確定」 → modal 關閉 → 回到列表頁
browser_network_requests顯示POST /api/records回 200- UI 列表計數仍然是「共 1 筆」
如果是人手 UAT,看到 modal 關閉、沒紅色錯誤、就會打勾收工。「列表沒立刻更新」常被歸咎於 cache 或頁面 refresh 時機,使用者頂多 F5 重整一次,看到還是 1 筆,可能會懷疑是不是自己之前算錯了。
Agent 不會這樣讓步。它直接做了一件人手很少做的事——用 sessionStorage 裡的 token 直接打 API,繞過 UI 看資料的真實狀態:
// 透過 browser_evaluate 在頁面 context 內
// 借用 app 自己存的 token,繞過 UI 看資料
async () => {
const token = sessionStorage.getItem('app_token')
// populate=* 是 Strapi 慣例:預設 API 不會展開 relation 欄位,
// 必須明確指定才會把關聯資料一起回來
const r = await fetch(
'/api/records?filters[owner][id][$eq]=213&populate=*',
{ headers: { Authorization: 'Bearer ' + token } }
)
return (await r.json()).meta.pagination.total
}
// 回傳:1 (跟 UI 一致)
// 但用 createdAt desc 全表查,新建 record 確實存在於資料庫
兩個查詢結果矛盾:用 owner filter 撈不到,但全表 sort by createdAt 看得到。這時候真相只能再深入一步:直接 populate 那筆 record 的所有 relation。結果是新 record 的 primary owner 連結正常,但走 partial cut-over 後新引入的 ownerUser 連結為 null。
揭發的 backend bug:dual-write lifecycle hook 失效
要看懂這個 bug 先補三個概念。
Link table(中介表 / junction table):關聯式資料庫表達多對多關聯的標準做法。records 跟 users 之間如果是多對多,會用一張 records_users_lnk 表存 (record_id, user_id) 對應。Strapi 把每個 relation 自動建一張這種表。
Lifecycle hook:ORM 在 create / update / delete 前後 fire 的 callback。例如 beforeCreate 可以在資料寫入前自動填欄位、補預設值、或做驗證。
Dual-write 與 partial cut-over:schema 遷移的常見策略。當要把舊的 owner(指向 legacy 表)漸進換成新的 ownerUser(指向統一的 users 表),無法一次切換,就同時保留兩個欄位、寫入時兩邊都寫(dual-write)、讀取則先切到新 schema(partial cut-over),舊欄位留著當 backward compatibility。
回到這次的故事。後端正在做這個遷移:舊的 owner 連結保留,新的 ownerUser 連結並行寫入,find controller 已經切到走新連結 join。為了避免前端要改寫,後端設計了一個 lifecycle hook(autoFillPair):當前端只送 owner 時,hook 會在 beforeCreate 階段自動把對應的 ownerUser 填好。
flowchart TD
A[Frontend POST<br/>only sends owner] --> B[Strapi REST controller]
B --> C{lifecycle hook<br/>autoFillPair triggers?}
C -->|expected| D[fill ownerUser<br/>both link tables written]
C -->|actual: NO| E[only owner link written<br/>ownerUser link NULL]
D --> F[find controller via<br/>ownerUser join sees record]
E --> G[find controller via<br/>ownerUser join filters out]
G --> H[UI: list shows '共 1 筆'<br/>data is invisible]
classDef ok fill:#d4edda,stroke:#28a745,color:#155724
classDef bad fill:#f8d7da,stroke:#dc3545,color:#721c24
classDef warn fill:#fff3cd,stroke:#ffc107,color:#856404
class D,F ok
class E,G,H bad
class C warn
Backend session 接手後挖到的真因比想像中更隱晦:lifecycle hook 有觸發,但內部一個 helper extractDocId 只認得 documentId 字串跟 {connect: ...} envelope,而 frontend 送的是裸 number(例如 owner: 213)。Helper 拿到 number 直接 early return,hook 後續寫 ownerUser 的程式碼根本沒跑。
Strapi v5 的一個容易被忽略的細節:每筆資料同時有 numeric
id(traditional pk)跟字串documentId(v5 新引入用於 draft & publish)。寫 lifecycle helper 時很容易只想到 documentId 路徑,但 REST API 仍接受 numeric id 作為 relation 寫入輸入。
修法是把 helper 換成 extractRelationRef,回傳 tagged union {kind: 'doc' | 'num', value},讓上層根據 kind 選對的查詢欄位。同時補了 4 個 regression unit test 涵蓋 number / numeric string / bare {id} / documentId / connect 五種輸入形式。整個修補從 backend session 接手到 STG 驗證通過約半天,frontend 驗證腳本已經完成自己的職責:修補本身有效(POST 不再回 400),同時把獨立的 backend regression 完整揭發、留下清楚的 reproduction recipe 給下一棒。
為什麼人手 UAT 通常碰不到這個
回頭看人手 UAT 的標準動線就懂了:
| 步驟 | 人手 UAT | LLM agent |
|---|---|---|
| UI 守門 | ✓ 點點看 | ✓ snapshot + evaluate 雙驗 |
| 正常流程 | ✓ 看 toast | ✓ 看 toast 也看 network |
| 列表更新 | F5 一次,沒更新就頂多回報 | 直接打 API 比對 metadata + total |
| Relation 完整性 | 不在腳本內 | 自然會 populate 確認 |
| Cleanup orphan | 容易忘記 | 主動列出來請 backend 處理 |
人手 UAT 的盲點不是能力問題,是動機問題:人手點完十個案例已經累了,看到 UI 沒爆就會想趕快收工;agent 沒有時間成本壓力,它「再多查一個 API confirm 一下」的邊際成本是零。
三個實戰陣圍:把這個習慣寫進 verification recipe
把今天踩到的經驗一般化成可重複的 recipe:
陣圍一:審潔(assertion completeness)。 任何「create / update」操作都應該至少驗 UI、API status、API response body 三個層面。如果操作會影響列表,第四層是「重新拉列表確認新資料出現在預期位置」。POST 200 是必要不充分條件——它只證明請求到了 server 沒拋 exception,不證明資料寫進對的關聯。
陣圍二:cleanup 責任明確。 Agent 在 STG 跑驗證時會留下測試資料。每個 verification recipe 結尾要有 cleanup 步驟(SQL 或 API delete),或至少把孤兒資料(orphan record)ID 寫進 handoff doc 由下個 session 處理。今天那筆 orphan record 已經寫進跨 session 協調文件,等修好後一起清。
陣圍三:idempotency 預設。 驗證腳本應該能重跑,不會因為前一次留下的狀態爆炸。例如場景 4 故意觸發「重複名稱被拒」,這就天然 idempotent;如果是場景 2 真的會建立新 record,腳本就要先檢查當下總數、做完後驗證 +1,而不是寫死「應該總共 5 筆」。
結語
Playwright MCP、LLM agent、CI/CD 這些都是工具。真正的價值在於 design:把驗證的責任從「讓使用者覺得沒爆」推進到「讓資料真的寫對」。
LLM 驅動的 E2E 沒有取代人手 UAT,而是把人手的時間從點擊解放出來,去做更需要判斷的事——例如審視驗證 recipe 本身是否覆蓋了所有「沉默失敗」的可能。今天這個 regression 不是 frontend 修補造成的,但如果沒有這次 bot 驅動的驗證、它可能要等下一輪客戶反饋才會被發現。
下次你寫 verification 腳本時,多寫那一行 populate=* 然後比對結果。多花的五秒可能會省掉下週的緊急 hotfix。
