這次實作最深刻的體會,是釐清了自動化(我概括為 programmable + scalable)的一個瓶頸—— UI 上的自動化測試死角,這也是 AI 時代下依然需要人類介入的部分。
若要往理想的 loop engineering[1]邁進,programmable + scalable 是最初的一步,但 UI 開發真能做到 100% 自動化測試、完全免除手動驗證嗎?本文將透過一場實戰來回答這個命題。
在 scalable 的前提下,無法被測試捕捉的 bug,就無法被證明已徹底修復。
常見的情境是,手測時隨機出現的 bug,若不能夠寫成自動化測試,即使修改後手測多次都正常,該修復依然極為脆弱、無法形成能夠閉合的 loop,即無法永續擴展。這種狀況可收斂為兩個核心問題:
- bug 的 reproduce 邏輯 → 理論上沒有任何 bug 是 random 的,只有還沒被掌握的路徑。
- testability → 哪類情境自動化測試捕捉不到?又該如何應對?
本篇以
STORY
頁面的捲動同步(scroll sync)的 E2E testing 實作來帶出 UI testing 關鍵問題。
loop engineering:
Peter Steinberger(OpenClaw 創作者、現任職於 OpenAI)他在 2026 年 6 月 7 日於 X.com 上發表了一段改變 AI 開發思維的名言:「你不該再對 AI 寫 prompt 了,你該做的是設計能自動幫你提示 AI 的 loops。」
另外 Addy Osmani(前 Google 工程總監) 於 2026 年 6 月 8 日正式發表了一篇名為 《Loop Engineering》 的技術專文。他在文中正式為這個新技能定名,並定義其核心為「Loop engineering is replacing yourself as the person who prompts the agent. You design the system that does it instead. A loop here can be thought of a recursive goal where you define a purpose and the AI iterates until complete.」。 https://addyosmani.com/blog/loop-engineering/
1. 前端 UI 特別難測之處
資料或數據邏輯多半是「輸入 → 輸出」,只要建立了完整資料對,就幾乎沒太多難題。而 UI 上 user behavior 的測試,難在它同時牽扯在一起的同步動作與事件可以很複雜:
- 狀態多且非同步:資料載入、捲動位置、動畫、使用者輸入彼此交錯。
- 和瀏覽器底層耦合:排版渲染、
scrollHeight、requestAnimationFrame、IntersectionObserver的觸發時機,都是瀏覽器內部在排程的,因此不論是 user 手測、還是 Playwright 這類自動化工具,只能送出點擊、捲動或等待,但送不出「在這個精確毫秒觸發 IO」,這種無法掌握的排程,會導致自動化測試與手動測試結果有落差,是手測時遇到隨機 bug 的原因之一,也可能造成自動化測試捕捉不到。 - timing / phase dependency:同一段程式,載入快 200ms 或慢 200ms,影響到 user 滾動操作的行為流暢度可能完全不同。
- *「看起來怪」卻不一定「量得出來」*畫面跳一下、閃一下、抖一下,肉眼有感,但要 reproduce 和寫成 assertion 很難。
- 真實輸入無法完美模擬:trackpad、滑鼠或手指的慣性捲動(使用者滑動力道決定慣性餘速快慢),和自動化工具(本站用 Playwright)送出的離散事件不是同一回事。P.S. 這一點會在後面詳述。
E2E testing 能顧到大部分預期的行為,但有些「真人操作+各種時序」混合出來的 bug 它抓不到,這種只能靠人手測維護,但這是好方法嗎?在 4. Pro tips:提升可測試性 會討論。首先,我們應該先確保能驗證的部分,及如何設計好 E2E testing spec,盡可能把自動化能掌握的部分擴大,確保其穩定性。
2. 用 AI 盤點行為、生成 E2E spec 的過程
要寫好測試,我推薦的順序是:先盤點行為,再決定測什麼,最後才生成 spec。
- 建立 behavior matrix:請 AI 把系統的可觀察行為分類列出:使用者行為、副作用、DOM 變動、捲動行為、非同步、邊界情境。這一步的產物是一張表格文件。
- 明確定義「不測什麼」:這比「測什麼」更重要。以本專案為例,我刻意排除三類:實作細節(請求參數/次數、AbortController 這類內部優化)、行為過程(捲動動畫怎麼跑不重要,只斷言動畫的結果,比如最後停在哪)、外部系統(第三方套件、CDN 是否真的命中快取)。
這三類的共通點是測試的 CP 值太低:綁定的細節多、換來的信心少,維護成本卻隨每次改動累加。 - 定測試 guideline:斷言以 DOM 為準(內容出現/消失、元素位置、兩欄對齊)。只有當行為在畫面上完全看不出來時,才破例改檢查 DOM 以外的訊號:使否發 API 請求、response header(對 CDN 的快取承諾)等。
- 最後生成 spec,每一條都回連到 behavior matrix 的某個行為。
照這個順序,AI 協助產出來的測試才知道自己在測什麼,而不是一堆一改就壞的 snapshot assertion。
behavior matrix 範例
以本站同步捲動功能為例,behavior matrix 可以這樣整理:
(本表只是節錄)
| 類別 | 肉眼可觀察行為 | 斷言目標(DOM 為準) |
|---|---|---|
| 使用者行為 | 點 sidebar 年份 → 兩欄跳到該年並置中 | 兩欄各有該年內容落在 viewport 中央帶 |
| side effect | prepend 時改寫 scrollTop 補償視口 | prepend 前後,同一個可見元素位置不動 |
| DOM 變動 | jump 用新 window 取代舊內容 | 跳 1900 後,DOM 裡不再有 1600 的內容 |
| 捲動行為 | 捲近頂/底 → 載入更早/更晚年份 | 更早年份的列出現在欄頂,且畫面上原本的內容不跳動(位移 <= 2px) |
| 非同步 | 載入還在飛時 jump → 遲到的 stale 資料直接丟棄,不混進畫面 | 延遲回應期間點 jump,最終 DOM 只剩新 window 的年份 |
| 邊界情境 | 已捲到最早/最晚年份 | 不再發出多餘請求(少數破例斷言 API fetching) |
以這張表為例,有效的測試就是這樣來的:重點在寫出「可驗證性的」spec,將 test case 立基在 DOM 的變動上,讓測試帶來貨真價實的 UI 穩定性,避免 false negative 與 false positive。
3. E2E 抓不到的 bug:一個真實案例
我手測碰到一個 bug:快速捲動 timeline 時,scroll sync(捲動同步:捲動 timeline 或 events 其中一欄,另一欄自動跟到同一年份的機制)為了讓 events 跟上,會 nudge(輕推)[2] events 去觸發 lazy load up(extend);結果停手後,events 卻自己一直往上載入、衝過 timeline 停住的年份。debug 的過程很能顯示出寫 E2E(Playwright)的侷限性。
首先,由我手動測出的 bug,並向 AI 提出並討論,AI 幫助從程式碼確認了機制:一個捲動動畫的目標是固定值、且會被另一段補償邏輯(restoreAnchor[3])反覆「撐大距離」,導致動畫的終止條件永遠不成立——變成一個停不下來、與使用者輸入無關的殭屍動畫。
兩者交疊,湊出這條連鎖:
我快速捲 timeline,然後停手
↓
sync:events 落後了 → 發動 nudge 動畫(目標=固定的 400px)
↓
400px 在 sentinel 觸發區內 → 觸發 load up
↓
慣性尾巴:停手後 timeline 仍滑行數百 ms
→ sync 不斷重新觸發 nudge,動畫一直被續命
↓
load up 落地:prepend + restoreAnchor(scrollTop += 推離 400)
↓
還活著的動畫下一幀又把 scrollTop 拉回 400
→ 距離被反覆撐大,「distance < 1 就停」永不成立 = 殭屍動畫
↓(此後不需要任何輸入)
events 被釘在觸發區內 → load up 連鎖觸發
→ 衝過 timeline 停住的年份,直到資料頂端
但 AI 怎麼樣都無法用自動化工具 Playwright 穩定重現。試了各種離散 wheel 事件的組合(一次大步、高頻小步、外加網路延遲)全部呈現「正確行為」。原因是:
這個 bug 是依賴 trackpad 慣性尾巴(inertia tail)[4]——手指離開後,捲動仍持續數百毫秒,不斷重新觸發動畫、讓它一直活著。而 Playwright 的合成事件(是離散的,非真人手動那種連續事件)做不出這件事:事件序列一送完,捲動就真的停了,沒有那條「停手後還有餘速慢滑」的尾巴,動畫沒被續命,bug 自然不出現。
推論確立後,修法本身反而很小:既然殭屍動畫是「活過了重定位」才成災,就在兩個會程式化重定位 events 的時機,都先呼叫 cancelSmoothScroll 把動畫殺掉:(a) restoreAnchor 做 prepend 補償之前、(b) jump 要 scrollIntoView 之前。動畫無法存活過任何一次重定位,自然變不成殭屍;後續的 load up 也回到 nudge 次數上限的節制,不再無限連鎖。
最後這個 bug 的修復沒有附一支 red→green 的回歸測試,因為根本重現不出來。取而代之的驗證是三件事:(1) 程式碼推理(殭屍動畫在兩個重定位點被結構性切斷)、(2) 全 test case 保持正確 no regression、(3) 我手測確認。
當自動化重現不了,就退回「機制推理 + no regression + 人工驗證」的三角驗證,並誠實標註「此修法無自動化回歸測試」。這比硬生一支綠測試、假裝有覆蓋率好多了。
nudge(輕推):scroll sync 發現 events 欄落後、目標年份又還沒載入時,不會直接暴力跳轉,而是把 events 的 scrollTop 緩緩推向固定的 400px——這個位置刻意落在頂部 sentinel 的 600px 觸發區內,讓 lazy load up 自然被觸發、把缺的年份補進來。
restoreAnchor(視口補償):load up 成功後,更早的內容會 prepend 到列表頂端,把原本的內容整體往下擠。為了讓使用者眼中的畫面不動,程式會把 scrollTop 加上新內容的高度(scrollTop +=)。視覺上是把人釘在原地;但對還在飛的 nudge 動畫來說,這一加,等於把 scrollTop 從 400 又推遠了。
inertia tail(慣性滑動軌跡)為什麼很難透過 E2E 工具重現?
inertia tail 是指手機上用手指/滑鼠滾輪/觸控板「快速甩一下(flick)」頁面時,手指離開螢幕後,畫面不會立刻停下,而是會順著慣性「繼續滑行一段距離,然後慢慢減速停下來」。
不同的瀏覽器、不同的螢幕刷新率(60Hz vs 120Hz)等,都會導致這條「尾巴」的滑行距離、微小動態和時間都不一樣。在本篇討論的 case 下,多少速度、多少距離的 inertia tail 才會導致 load up fail,要大費功夫用離散 scroll event 模擬出這效果是不太實際的。
4. Pro tips:提升可測試性
AI 很會執行,但設計可測性、判斷取捨、喊停這些要人來。以下是我認為投報率最高的幾件事:
-
從設計之初就問「這可以 spec 化嗎」
可測性不是事後補的,是架構決策。不能寫成 spec 的設計,每多一個,就多一塊只能靠手測防守的地盤(需盡量避免才好),系統就少一分 scalability。 -
把「測不了」逼成「測得了」
讓不確定變確定:注入延遲、mock 特定方向的回應、控制 epoch,把 race condition 從「偶爾發生」逼成 100% 穩定重現。另外像是上一節提到的慣性捲動這種測不到的東西,如果在規劃時注意到那不是必要功能,就建議不要納入開發,考慮用其他 UI 設計達成同樣體驗,降低後期維護成本。 -
測試的 output 要吐有用資訊
讓每支測試在 fail 時機能吐出 viewport 尺寸/位置數據、API trace、做了什麼 recovery gesture,而不是只給一個紅叉。這會讓 Debug 效率提升很多。 -
不測過頭
主動刪除那些過度綁定實作細節的 assert(像是硬寫死毫秒數、請求次數)。測試真正的價值,避免增加維護成本、降低改動後的 false negative。 -
把過程中的決策濃縮成文件(ADR)
每個被逼出來的 bug、它的機制、為什麼這樣修、哪些測試涵蓋/不涵蓋,寫進文件。下一次就不用重走一遍。
從第一點延伸出來,我想到 TDD 是否能提高 AI 輔助開發的效率,下節討論。
5. TDD 是個好方法嗎?
case by case。對前端 UI,純粹的「先寫測試再寫實作」常常不適用,但值得部分採用。
| 情境 | TDD? | 為什麼 |
|---|---|---|
| 明確的行為 spec(跳年份、邊界同步、viewport 穩定) | 適用 | spec 可先寫成斷言,實作去滿足它 |
| 已知但難重現的 bug | 部分適用 | 先寫「會紅的重現測試」再修,這是bug-driven,很有效 |
| 時序+真實輸入的 runaway | 不適用 | 連紅燈測試項都做不出來,無從 TDD |
| 視覺細節(跳動、閃爍、動畫手感) | 不適用 | 斷言不出來,靠手測與人眼 |
我實際採用的更像是「行為盤點在前、缺陷驅動在中、手測防線在後」:
- 可 spec 化的部分:接近 TDD,先定義可觀察行為,再讓實作滿足。
- bug-driven 的部分:發現 bug → 先寫一支能重現的紅測試 → 修到綠。這是 TDD 精神最實用的部分。
- 無法自動化的部分:不硬寫 false positive 的 testing,明確標註與紀錄,用手測與機制推理守住。
結論是,能寫成斷言的就 TDD;寫不成的,就老實用人力顧,並把「這裡沒有自動化覆蓋」記下來。真正的風險是假裝什麼都測得到。
6. Harness AI:兩份可直接複製的 prompt
把這篇的測試 guideline 濃縮成兩份 prompt,只包含「AI 預設不會做、需要你明講」的進階部分,不包含一些 AI 已經內建的基本功,比如讀 code、跑 typecheck/lint/test、遵循慣例。目標對象是前端開發!
(a) 開發 feature 時,餵給 plan mode:
# Feature plan — testability-first (advanced rules only)
Assume the basics. Add these:
- Behavior inventory BEFORE code. List user-observable behaviors as a table: user actions, side effects, DOM mutations, async/timing, edge cases. The plan derives from this table, not from the component tree.
- Fix each behavior's assertion target up front: a user-observable DOM fact (content appears/disappears, element position, cross-pane alignment) — never an implementation detail.
- Define what NOT to test, explicitly: request params/counts, debounce/timeout ms, internal optimizations (cache, abort, fast-path). They break on refactor with zero UX change. Assert network or "component exists" only when no DOM proxy exists for the behavior — and mark it a deliberate exception.
- Design for testability over tolerant tests. Before widening a tolerance or adding a flake-retry, propose a structural fix that removes the flakiness (e.g. move an element out of the scroll flow so a toggle cannot shift the viewport).
- Assert the user-facing EFFECT, not the mechanism or an element's mere existence.
- Name what automation cannot cover. Timing x real input (trackpad momentum, multi-touch) is often unreproducible — flag it and plan manual verification instead of pretending coverage.
(b) 捕捉並修復 bug 時:
# Bug fix — repro-first & honest (advanced rules only)
Assume the basics. Add these:
- Red before green. Reproduce the bug as a FAILING test before fixing. A test that passes but never reproduced the bug is worse than none — it is false safety. If you cannot make it red, say so before writing any fix.
- Force determinism. Turn "sometimes" into "always": inject latency, hold/delay a specific response, control epoch/version, drive the exact phase. Only a stable repro yields a real regression test.
- If the full scenario is unreproducible, test the INVARIANT, not the script — e.g. "trigger once -> stop input -> after idle, scroll position is stable and the sentinel has left its trigger zone."
- Some bugs cannot be synthesized (real input: momentum, touch). Do NOT fake a green test. Fall back to triple validation: (1) mechanism reasoning, (2) no new regressions across the suite, (3) manual verification — and label the fix "no automated regression coverage."
- Honesty over coverage theater. Distinguish "I reproduced it" from "I reason it is true," and state which. Show evidence — before/after numbers, trace, screenshot — never claim "fixed" from words alone.
- Fix the cause, not the symptom. Prefer a root-cause / architecture fix over patch + looser test. Needing a wider tolerance to pass is a signal the fix is wrong.
- Make failures diagnosable: on assertion failure emit geometry / state / API-trace, not just a red X.