你有 90% 的程式碼覆蓋率,凌晨兩點還是被 on-call 警報吵醒。

單元測試全過了。CI 也是綠燈。bug 還是進了正式環境。覆蓋率沒有說謊,但也沒有說出真相。它只衡量了哪些行被執行過,沒衡量哪些行為真的被驗證過。

大多數團隊都是吃過苦頭才學到這一課。他們寫了幾百個單元測試,看著覆蓋率徽章變綠,就以為堡壘固若金湯。堡壘確實有牆。但它沒有屋頂。

單元測試只測你想像得到會出錯的地方

單元測試驗證的是你對程式碼的假設。問題是,bug 根本不鳥你的假設。

想想一個簡單的計價函式:

def calculate_total(items, tax_rate):
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    tax = subtotal * tax_rate
    return round(subtotal + tax, 2)

一套典型的單元測試看起來很穩:

def test_calculate_total_with_tax():
    items = [{"price": 10.00, "quantity": 2}]
    assert calculate_total(items, 0.08) == 21.60

def test_calculate_total_empty_cart():
    assert calculate_total([], 0.08) == 0.00

兩個都過了。覆蓋率 100%。函式上線。

然後一個日本顧客結帳,買了三個各 ¥100 的商品。稅率是 0.10。預期總額是 ¥330。函式回傳 ¥330.00。沒問題。

一個瑞士顧客買了一件 CHF 12.35 的商品,VAT 7.7%。預期:CHF 13.30。實際:CHF 13.30。也沒問題。

然後一個顧客在俄勒岡州買了兩個 $0.01 的商品,當地稅率是 0.0。預期:$0.02。實際:$0.02。過。

bug 出現在一位顧客所處的司法轄區,稅率欄位是 None(因為稅務服務對一個不認識的郵遞區號回傳了 null),結帳時函式把 subtotal * None 相乘,直接噴出 TypeError。你的單元測試從來沒傳過 None 當稅率,因為你假設它永遠會是 float。

這就是單元測試的根本限制。它只會走你想過要測的路徑。bug 住在那些你沒想到的路徑裡。

單元測試碰不到的四個地方

Integration Boundaries

單元測試把外部相依換成 mocks。Mocks 很有禮貌。它們只做你吩咐的事。真正的 API 可不這麼客氣。

你的 mock 資料庫毫秒級回傳資料列。正式環境要秒級,或 timeout,或因為 read replica lag 回傳重複資料列——而這種 lag 你根本不知道存在。

你的 mock HTTP client 回傳乾淨的 JSON。真正的服務在星期二回傳 200 但附帶空 body。

Mocks 測的是你的程式碼對「其他系統的假設」。正式環境測的是你的程式碼對「現實」的反應。這是兩套不同的測試,有不同的通過率。

Stateful and Temporal Bugs

單元測試各自獨立執行。每個測試都拿到全新的狀態。正式環境是長期執行的程序,狀態會累積、洩漏、彼此交互作用。

負載一高就把資料清掉的 memory cache。一萬次請求後把自己耗盡的 connection pool。橫跨夏令時間切換時會出錯的時間戳比較。這些 bug 需要時間、量級或特定順序才會顯現。單元測試一樣都沒有。

Concurrency and Race Conditions

兩個使用者同時更新同一筆資料。一個請求讀了餘額,另一個扣了款,第一個把舊值寫回去。錢就這樣消失了。你的單元測試在單一執行緒裡依序跑。抓不到這種事。

你可以為個別的 locking primitive 寫單元測試。但你無法寫一個單元測試來證明整個系統沒有 race condition。狀態空間太大,時間點太不確定。

The Environment Itself

你的測試跑在 Ubuntu 22.04、Python 3.11、4GB RAM、沒有firewall規則的環境。正式環境跑在 Alpine Linux、Python 3.11、512MB RAM,還有一個 security group 會在 60 秒後斷掉閒置的 TCP 連線。

socket 模組行為不同。mmap 上限更低。locale 設定讓 strftime 輸出你的 parser 預料之外的日期格式。這些不是程式碼 bug。它們是 context bug。單元測試沒有 context。

為什麼 Coverage Percentage 會誤導你

覆蓋率工具衡量的是「哪些行被執行」,不是「斷言品質」。一個測試可以跑完函式的每一行,卻什麼有意義的都沒驗證。

def test_poor_coverage_quality():
    result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
    # Executed 100% of lines. Verified almost nothing.
    assert result is not None

這個測試給你 100% 行覆蓋率,零信心。很多團隊之所以優化這個指標,只是因為它很好量測。信心很難量測。所以他們量測覆蓋率,然後希望兩者相關。

它們不相關。

你該改測什麼(或額外補測什麼)

這不是反對單元測試的論點。單元測試快速、確定性高、非常適合驗證演算法邏輯。它們只是不完整。

以下是能在不把你的 CI pipeline 變成 45 分鐘累贅的前提下,補上缺口的做法。

Test at System Boundaries, Not Just Internals

與其 mock 資料庫,不如寫測試去碰真正的測試資料庫。這些測試比較慢,所以要選擇性執行。但它們能抓出你的 ORM 查詢與 query planner 實際行為之間的落差。

與其 mock HTTP client,不如把下游服務用 container 跑起來。這能抓到 schema drift、timeout 行為,以及只在真實連線失敗時才會觸發的 retry logic。

Add Contract Tests for External Services

如果你沒辦法在 CI 裡跑真正的相依服務,就用 contract tests。這些測試驗證你的 consumer 預期是否與 provider 實際的 API schema 一致。

Pact 這類工具會記錄你的服務與其相依之間的交互。如果 provider 改了欄位型別或拿掉端點,contract test 會在程式碼部署前就失敗。這不如 integration testing,但遠比祈禱你的 mocks 夠準確來得好。

Use Property-Based Testing for Edge Cases

Hypothesis(Python)或 fast-check(JavaScript)這類 property-based testing 工具會產生數千組隨機輸入,驗證你的 invariants 是否成立。

from hypothesis import given, strategies as st

@given(
    st.lists(st.fixed_dictionaries({
        "price": st.decimals(min_value=0, max_value=10000, places=2),
        "quantity": st.integers(min_value=0, max_value=1000)
    })),
    st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
    if tax_rate is None:
        with pytest.raises(TypeError):
            calculate_total(items, tax_rate)
        return

    result = calculate_total(items, tax_rate)
    assert result >= 0
    assert result == round(result, 2)

這個測試會在你沒想到要寫那個特定案例的情況下,就抓到 None 稅率的 bug。它會產生你永遠不會想到的輸入:空清單、超大清單、零價格、最高精度的 decimal。它會在你還沒想像到之前就找到你邏輯的邊界。

Monitor Production Like It’s a Test Environment

最誠實的測試套件就是正式環境的流量。如果你沒辦法在上線前抓到 bug,那就在它造成傷害前抓到。

用 feature flags 先對 1% 的使用者推出變更。觀察錯誤率、延遲百分位數與業務指標。單元測試告訴你程式碼在隔離環境中是否符合預期。正式環境監控告訴你程式碼在現實中是否符合預期。

設定異常警報,不只是硬錯誤警報。部署後 500 錯誤上升 5% 通常就是 race condition 或資源洩漏開始出現的唯一訊號。單元測試永遠不會告訴你這件事。

誠實的取捨

單元測試便宜、快速、適合開發者的回饋迴路。Integration tests 昂貴、緩慢、適合抓真正重要的 bug。

兩者你都需要。陷阱在於以為 100% 單元測試覆蓋率就代表其他可以跳過。它只代表你徹底測完了簡單的部分。困難的部分——那些讓你半夜驚醒的部分——住在你的測試沒在看的地方。

從單元測試開始驗證邏輯與演算法。在每個 system boundary 加上 integration tests。用 property-based testing 去找你沒想到的輸入。監控正式環境來抓所有測試都漏掉的問題。

Coverage 是虛榮指標。唯一重要的指標是,你今晚能不能一覺到天亮。


如果你想抓那些測試總是漏掉的 bug,先從看錯誤資料開始。Sentry 會告訴你正式環境裡什麼東西壞了,還附上你的單元測試永遠拿不到的stack trace與 context。