TDD 與自動化測試

先寫測試的價值不在紀律,在於你會親眼看到它失敗一次——沒紅過的測試有可能永遠是綠的, 而它照樣算進覆蓋率。這篇記三件事:Red 這一步在驗什麼、測試環境怎麼選、以及 mock 的界線。

系列・Nuxt 4 全棧實戰筆記・第 11/12 篇

先寫測試最常見的辯護是紀律問題——強迫你想清楚需求、強迫測試不被跳過。這些說法都對,但它們解釋不了為什麼順序重要。

真正的理由只有一個,而且很具體:你會親眼看到那個測試失敗一次。

一、Red 這一步驗的是測試本身

一個從來沒有紅過的測試,有可能永遠是綠的。它照樣會被執行、照樣進覆蓋率報告、照樣讓人安心。

會發生這種事的原因很平凡:斷言寫得太寬鬆(expect(result).toBeDefined())、測到了錯的東西、或者 mock 把被測邏輯整段換掉了。這些錯誤不會有任何徵兆——測試通過就是它唯一會給你的訊號,而它給錯了。

先寫測試、確認它因為「功能還不存在」而失敗,就順手排除了這一整類問題。這是三個步驟裡最容易被跳過、也最不能跳過的一步:

Red      寫測試,執行,確認它失敗——而且失敗的原因是你預期的那個
Green    寫剛好能讓它通過的實作,不提前優化
Refactor 整理程式碼,測試必須保持綠燈

先寫實作再補測試也不是不行,但補完之後要記得故意把實作改壞一次,確認測試真的會紅。少了這個動作,你補的是覆蓋率,不是保護。

二、測試環境是速度與真實度的取捨

Vitest 在 Nuxt 專案裡有兩種跑法,差別不在寫法,在啟動成本:

適用代價
Node 環境純函式、格式化、計算、驗證 schema拿不到任何 Nuxt 的東西
Nuxt 環境需要 composable、auto-import、元件掛載每個檔案都要付一次環境啟動

判準是:這段邏輯需不需要 Nuxt 才能執行。 不需要就放 Node 環境——這類測試通常在毫秒級完成,你才會願意在每次存檔時跑它們。

實務上這會反過來影響設計:把商業邏輯從元件裡抽出來變成純函式,不只是為了好測,而是為了讓它測起來夠快。測得慢的測試最後都會被關掉。

三、mock 到什麼程度才不會測到自己

mock 的目的是隔離外部依賴——網路、資料庫、時間。但每 mock 掉一層,測試涵蓋的真實範圍就少一層。

vi.mocked($fetch).mockResolvedValue({ data: mockUsers })

這行讓你能測「拿到資料之後怎麼處理」,但同時也表示「請求有沒有正確組出來」不在這個測試的守備範圍。這是合理的分工,前提是你知道那一半沒被測到。

界線大致是:mock 邊界,不 mock 邏輯。 資料庫 client、HTTP 呼叫、當前時間可以 mock;被測函式自己呼叫的另一個自家函式不要 mock——mock 了之後測試通過只證明「我的 mock 回傳了我預期的值」。

有兩種測試不必寫:測框架本身(Vue 會不會渲染元件不是你的責任)、測私有函式(它們會透過公開介面被涵蓋,直接測會讓重構寸步難行)。

那覆蓋率要到多少?這個數字本身沒有意義——它量的是「有多少行被執行過」,不是「有多少行被驗證過」。一個把所有函式都呼叫一遍、但斷言全部寫成 toBeDefined() 的測試套件可以輕鬆做到九成覆蓋率,而它什麼都保護不了。比較有用的問法是:**把某一段核心邏輯故意改壞,有沒有測試會紅?**紅了才叫有覆蓋。

四、把檢查串成一條,順序有意義

{
  "scripts": {
    "check": "pnpm format && pnpm lint && pnpm typecheck && pnpm test"
  }
}

順序不是隨便排的:格式化會改檔案,所以要在檢查之前;型別錯誤會讓大量測試以難懂的方式失敗,所以型別要先過。前面任一步失敗就停下來,後面的輸出對排錯沒有幫助,只會把真正的錯誤訊息推到捲軸外面。

最後一條規則沒有例外:it.skip 是技術債,不是暫時措施。 被跳過的測試不會有人回來開啟它,而它在報告裡看起來跟通過幾乎一樣。真的不需要就刪掉,需要就修好——留著一個永遠不跑的測試,比沒有那個測試更糟,因為它會讓人以為那塊有保護。