視覺完成 ≠ 功能完成
核心原則
視覺完成是「畫面看起來對」、功能完成是「使用者意圖真的被滿足」。 兩者在簡單情境下重合、在邊界情境下分裂。視覺完成出現得早(手動 happy path 一試就過)、功能完成需要刻意對照「使用者意圖完整集合」才看得出來。
寫程式時把「畫面對了」當成完工訊號 = 把驗收標準降到視覺層、漏掉「功能在邊界情境下是否還對」這層。
為什麼視覺驗收會早於功能驗收成立
驗收訊號的成本梯度
| 驗收方式 | 觸發成本 | 覆蓋的失敗類型 |
|---|---|---|
| 手動視覺驗收 | 低 — 開頁、輸入一個 case | Happy path 的視覺正確 |
| 多 case 視覺驗收 | 中 — 想出邊界 case | 視覺面的邊界 |
| 功能對照(語意驗收) | 高 — 列使用者意圖完整集 | 功能跟意圖之間的縫 |
| 跨資料規模驗收 | 高 — 製造稀疏 / 大量資料 | 資料規模相依的功能失敗 |
成本低的訊號出現早 → 容易誤判完工。
視覺驗收的盲區
視覺驗收只看「螢幕上呈現的」、不看「應該呈現但沒呈現的」。後者沒有視覺訊號 — 不會閃紅、不會報錯、只是「該有的東西沒出現」。
這個盲區包括:
- Filter 把該顯示的藏掉了(見 #55 Filter 與 Source 的層錯位)
- Pagination 漏抓了某幾頁
- Sort 漏了某類元素
- Async race condition 把舊資料留在畫面
共通點:錯誤的形式是「不該不在的不在」、不是「畫面壞了」。
多面向:四類「畫面對但功能漏」
面向 1:Filter / Sort / Count 跟 source 不同層
見 #55。視覺層 filter 套在分批 source 上、稀疏 case 顯露語意縫。
面向 2:Async race / 競態
1input.addEventListener('input', async () => {
2 const r = await search(input.value);
3 render(r); // 慢的 query 後到、畫面是舊 query 的結果
4});畫面有結果、看起來對、但對應的不是當前 query。
面向 3:Empty state / loading state 不分
1<div class="results">
2 {{ if results }}{{ for r }}{{ render r }}{{ end }}{{ end }}
3</div>「還在 loading」跟「真的沒結果」共用同一個畫面 — 都是空。視覺對、功能上「使用者不知道狀態」。
面向 4:Form submit 後狀態回饋失真
1button.onclick = () => { saveData(); button.textContent = "Saved"; };按了顯示 saved、但 saveData 是 async 還沒完成 / 失敗 — 畫面對、實際資料沒進 DB。
四個面向共用結構:動作有視覺回饋、但回饋的「時機」或「對象」跟「實際語意」對不上。
「畫面對」屬於哪個 checkpoint
驗收要分散在四個時點(寫之前 / 開發中 / ship 前 / ship 後)— 詳見 #68 驗收的時間軸:四個 checkpoint。
「畫面對」是 開發中 的視覺驗收訊號 — 用來判斷「邏輯有跑、UI 沒崩」。它不能取代:
- 寫之前的「意圖完整集列舉」
- Ship 前的「邊界 / 規模 case」
- Ship 後的「真實使用者紀錄」
把「畫面對」當完工 = 把開發中的中介訊號當終點訊號 = 跳過後三個 checkpoint。
跟 #42「2 次門檻」的關係
#42 2 次門檻 講「第 1 次成功是低資訊量訊號、第 2 次(同方向 / 同類)才是真訊號」。
「畫面對」就是 #42 在「驗收訊號」面向的應用:「畫面對了一次」是低資訊量訊號、跟「程式跑通一次」「測試過一次」是同類。它告訴你「至少不是完全壞的」、不告訴你「對了」。
| 低資訊量訊號 | 真訊號 |
|---|---|
| 畫面對了一次 | 跨多個 case、多個規模、跨時間後仍對 |
| 程式跑通一次 | 跨多次執行、不同輸入仍跑通 |
| 測試過一次 | 涵蓋邊界 / 失敗 / 規模、CI 持續通過 |
| 使用者用過一次沒反映 | 多週多使用者沒累積反映 |
把低資訊量訊號當完工 = 跨情境就是「同方向加碼到第 3 次」 — 都是「太早信任早期成功」的同個錯誤。
識別「視覺完成但功能未完成」的訊號
訊號 1:驗收靠「再點一下試試」
如果發現 bug 的方式是「我再操作一次就看出來了」 — 表示 happy path 過了、邊界 case 沒過。看到這個訊號要主動列邊界 case。
訊號 2:使用者描述的 bug 含「有時候」「偶爾」「我以為」
「有時候 load more 沒動」「我以為都篩過了」 — 這類語言反映的是「畫面跟意圖之間有縫、使用者用視覺驗收結果跟意圖對不上」。
訊號 3:實作時下意識覺得「先這樣、晚點補」
1// TODO: 處理 cache 跟 fresh 的合併
2const data = cached || fresh;「晚點補」的部分通常就是視覺看不見的功能缺口。如果視覺驗收會過、TODO 會被忘記到 production。
訊號 4:測試只有 happy path 截圖
PR / commit 附的截圖只有「最常見的 case」 — 沒有「沒結果」「載入中」「失敗」「資料規模特別大 / 特別小」的截圖 → 驗收層級停在視覺。
設計取捨:怎麼把驗收從視覺升到功能
四種做法、不同情境合理。
A:寫之前列「使用者意圖的完整 case 集合」、實作後逐一對照
- 機制:開工前列 happy path / 邊界 case / 失敗 case 三類、實作完逐一檢查
- 選 A 的理由:把驗收標準從「能用」升到「對齊意圖」
- 代價:需要主動想 case、寫之前花時間
B:靠自動化測試(unit / e2e)覆蓋邊界
- 機制:每個 case 寫一個測試、CI 跑
- 跟 A 的取捨:B 持續性更好、但成本高、且測試是寫的人決定的、漏想 case 一樣會漏
- B 才合理的情境:大專案、團隊協作、回歸風險高
C:靠使用者回報
- 機制:先 ship、使用者反映再修
- 跟 A 的取捨:C 工程量最低、但 trust 損失高、bug 進 production 才被發現
- C 才合理的情境:原型期、使用者願意幫忙找 bug、易回滾
D:只做視覺驗收(反模式)
- 為什麼是反模式:把驗收標準降到視覺層、漏掉「功能跟意圖之間的縫」這層 — 而那層的失敗最常見也最貴
- 看起來吸引人的原因:成本最低、happy path 過了就 OK、不需要列邊界 case
- 實際發生的代價:silent 缺口累積、系統性使用者不信任、ship 後發現修起來比早期貴 N 倍(見 #68 瀑布原則)
判讀徵兆
| 訊號 | 該做的行動 |
|---|---|
| 驗收只看了 happy path 截圖 | 補:邊界 case + 失敗 case + 規模 case |
| 內心 OS:「畫面對了應該就 OK」 | 停 — 列「使用者意圖完整集合」對照 |
| Bug report 含「有時候」「偶爾」「我以為」 | 是「畫面跟意圖之間有縫」的訊號 |
| 實作時寫了 TODO 但視覺驗收會過 | TODO 會在 production 被遺忘、必須補完 |
| Filter / sort / async / cache 等「狀態相依」的功能完成 | 主動跑「規模 / 稀疏 / 競態」三類 case |
核心原則:視覺驗收是必要、不是充分。功能驗收要對照「使用者意圖完整集合」、不只是「畫面對」。視覺對 + 意圖縫 = 比畫面壞更危險、因為它不會觸發任何訊號。
延伸到測試驗收:「測試 PASS」也是視覺訊號的同類 — 沒看過該測試 RED 過、不知道它有沒有 catch 能力。詳見 #69 Test-First:先看到 RED 才相信 GREEN。