跑 100 次測試是騙人的:如何真正決定 Property-Based Test 的執行次數
如果你用預設的 100 個範例來跑 property-based tests,那你等於同時承受了兩種壞處。你的 CI 比實際需要還慢,而且那些真正重要的 bug 你還是抓不到。
這個數字沒有什麼魔力。大多數函式庫,包括 Hypothesis 在內,預設設成 100 只是因為它是個整數,感覺很安全。但「感覺很安全」不是測試策略。
Property-based testing 到底保證了什麼
Property-based testing 顛覆了單元測試的邏輯。你不再手寫輸入與預期輸出,而是定義一個 property。一條永遠應該成立的規則。框架會產生輸入來試圖打破它。
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
assert lst == list(reversed(list(reversed(lst))))
框架會用隨機的整數列表多次執行這個函式。如果找到反例,它會把輸入縮小到仍能觸發失敗的最小版本。一個觸發 bug 的 47 個元素列表對除錯沒什麼用,但 3 個元素的列表就是金礦。
這很強大。但它也是機率性的。Property-based testing 無法證明正確性。它只能提高你對「某個 bug 不存在」的信心,或是在 bug 存在時找到它。這種機率本質正是讓執行次數如此重要的原因。
為什麼 100 是隨便定的
老實說,100 這個數字從哪來的。在 Hypothesis 中,它是 2015 年選定的預設值,因為它是個好看的整數,能在不讓測試變得難以忍受地慢的情況下抓到大多數 bug。這是一種社交妥協,不是統計妥協。
找到 bug 的機率取決於兩件事:bug 在輸入空間中出現的頻率,以及你取了多少樣本。如果一個 bug 只有在輸入是長度超過 20 的回文時才會觸發,而回文只占所有列表的 0.01%,那麼 100 次執行讓你抓到它的機率大約是 1%。這不是測試,這是樂透。
大多數 bug 沒有那麼罕見。許多 property 會在空列表、單一元素或簡單的重複值上出錯。一個調校良好的 generator 很快就能抓到這些。但預設的 100 次假設你的 generator 是完美的、你的 bug 都很淺。這兩個假設都是錯的。
執行次數到底在統計上買到了什麼
如果我們把發現 bug 建模為「從一個 bug 出現機率為 p 的輸入空間中進行有放回抽樣」,那麼 n 次執行後仍遺漏 bug 的機率是 (1 - p)^n。
當 p = 0.01 時,100 次執行讓你有 37% 的機率遺漏 bug。當 p = 0.001 時,100 次執行讓你有 90% 的機率遺漏它。要想有 99% 的信心抓到出現率為 0.1% 的 bug,你需要大約 4,600 次執行。
import math
def runs_for_confidence(p, confidence=0.99):
"""Returns the runs needed to catch a bug with probability `p`
at the given confidence level."""
return math.ceil(math.log(1 - confidence) / math.log(1 - p))
print(runs_for_confidence(0.01)) # 459
print(runs_for_confidence(0.001)) # 4603
print(runs_for_confidence(0.0001)) # 46050
這就是讓人不安的部分。如果你想對罕見 bug 有高度信心,你需要數萬次執行。沒有人想在 CI 中等那麼久。
Shrinking 改變了成本方程式
100 次執行的預設值是在 shrinking 還沒有今天這麼強大之前設定的。現代的 property-based testing 框架不只是找到 bug,它們找到最小的 bug。
這意味著你可以用「預算」來思考,而不只是「次數」。如果你跑 1,000 個範例,在第 847 次找到 bug,shrinking 可能還需要再執行 200 到 300 次來最小化反例。單一個 bug 的總成本就是 1,100 次以上。但如果你跑 10,000 個範例什麼都沒找到,你花了 10,000 次執行換來安心。
訣竅在於把「發現」和「驗證」分開。在 CI 中跑一個小而快的測試組合來取得即時回饋。在夜間排程或 release branch 上跑一個更大、更慢的組合來獲得更深的信心。
from hypothesis import given, settings, strategies as st
import json
# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
assert json.loads(json.dumps(d)) == d
# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
assert json.loads(json.dumps(d)) == d
這不只是速度的問題,而是資訊密度的問題。一個通過 100 次執行的測試幾乎什麼都沒告訴你。一個通過 5,000 次執行的測試告訴你多一點點。一個在 100 次執行中失敗的測試則準確告訴你該去哪裡找問題。
我們如何將 property-based tests 分層
根據我們的經驗,最好的方法是停止把所有 property 當成平等的。我們把它們分成三層。
Fast properties 在每次 pull request 時執行。這些是機械性的。Round-trip serialization、deduplication 的idempotency、資料結構上的基本不變量。我們跑 100 到 200 個範例。它們在不到一秒內完成。
Deep properties 在每次合併到 main 時執行。這些針對複雜的 state machine、事件處理管線,以及任何有組合爆炸的東西。我們跑 2,000 到 10,000 個範例。它們需要數分鐘,而非數小時。
Exploratory properties 在 release 前手動執行。這些是我們把 max_examples 調到 50,000 甚至更高,讓機器慢慢跑,同時我們審閱 changelog 的測試。我們用這種方式找到過 race condition 和整數溢出的邊界案例,這些是任何單元測試都抓不到的。
與其猜測,不如這樣做
不要再把 max_examples 當成轉一次就忘記的旋鈕。把它當成屬於 property 本身的設定,而不是框架的設定。
為你寫的每一個 property 問三個問題。
這個測試跑起來多貴?如果每個範例要 50 毫秒,10,000 次就是 8 分鐘。如果只要 5 毫秒,那就是不到一分鐘。
如果我們遺漏了這個 bug,後果有多嚴重?log 訊息裡的格式錯誤,跟付款管線裡的資料損毀 bug,完全不是同一回事。
觸發條件有多罕見?如果 bug 只出現在閏年、或當兩個 UUID 碰撞時、或剛好在 INT_MAX 時,你需要更多執行次數,或更聰明的 generator。
更聰明的 generator 幾乎總是勝過更多執行次數。如果你在測試 JSON parser,不要產生隨機字串然後祈禱它們能解析。產生有效的物件,然後再變異它們。
from hypothesis import given, settings, strategies as st
import json
# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
try:
json.loads(s)
except json.JSONDecodeError:
pass # Most inputs hit this immediately
# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
assert json.loads(json.dumps(d)) == d
500 次執行搭配好的 generator,永遠勝過 10,000 次執行搭配爛的 generator。
關於決定 property-based test 規模的常見問題
跑更多次不就代表更好的覆蓋率嗎?
不完全是。Property-based testing 在傳統意義上沒有覆蓋率指標。更多執行次數會提高找到 bug 的機率,但邊際效益遞減得很快。從 100 次加倍到 200 次是有意義的。從 10,000 次加倍到 20,000 次則很少有意義。
那 fuzzing 呢?它不就是把 property-based testing 跑幾百萬次嗎?
Fuzzing 是相關但不同的東西。Fuzzer 通常跑數百萬個輸入,而且對領域沒有語義理解。Property-based testing 使用結構化的 generator 和 shrinking。你可以把 PBT 想成聰明的 fuzzing,或把 fuzzing 想成暴力型的 PBT。執行次數的計算方式不同,因為每次執行的成本與每次執行帶來的資訊都不一樣。
我應該在 CI 裡把 max_examples 設高還是設低?
CI 設高,本地開發設低。你的筆電是為了速度,CI 是為了信心。用 settings profile 或環境變數來切換。
import os
from hypothesis import settings
CI = os.environ.get("CI", "false").lower() == "true"
settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)
settings.load_profile("ci" if CI else "dev")
我怎麼知道我的 generator 夠不夠好?
把 max_examples 設得很高,例如 50,000,然後觀察覆蓋率報告。如果還有分支沒被執行到,你的 generator 就沒有覆蓋到它們。在降低執行次數之前,先修好 generator。
與其尋找完美的執行次數,不如開始測量
Property-based testing 沒有一個放諸四海皆準的「正確」執行次數。只有對你的 property、你的 generator、你的 CI 預算,以及你想預防的 bug 的成本來說「正確」的數字。
如果你非從 100 開始不可,那就從 100 開始。但對於守護關鍵路徑的 property,要往上調;對於只是 sanity check 的 property,要往下調。測量你的測試花了多久。分析你的 generator。而且要記住:一個通過 100 次的 property-based test 不是證明,它只是證據。
如果你想更深入,Hypothesis 關於 test statistics 和 targeted property-based testing 的文件值得一讀。hypothesis CLI 可以精確顯示你的測試把時間花在哪些範例上。這是你在決定要把旋鈕往上或往下轉時,第一個該看的地方。