今天想聊 Trail of Bits 的 property-based-testing。跟 Day 6、Day 9 講過的 static-analysis、CodeQL 同一家出品,但這次不是掃描工具,是一套教你怎麼寫測試的技能——具體來說,是教你什麼時候該用「性質測試」(property test)取代一般人手寫的「範例測試」(example test)。
這個技能的賣點聽起來很吸引人:與其自己想幾個測試案例,不如寫一條規則,讓程式自動生成成千上萬種輸入去驗證這條規則,找到反例算它贏。我自己一開始也是被這個賣點吸引才想測它,結果測完發現,真正該記住的一課,反而不是「亂數幫你找到了 bug」,是亂數這件事本身,遠比我以為的更不可靠。
property-based-testing,作者 Henrik Brodin(Trail of Bits),支援 Hypothesis(Python)、fast-check(JS)、proptest(Rust)、jqwik、rapid,以及 Solidity 智能合約用的 Echidna、Medusadecode(encode(x)) == x)、Inverse(f(g(x)) == x)、Idempotence(f(f(x)) == f(x))、Invariant(轉換前後都成立);強度排序從弱到強是「不當機 → 型別保留 → 不變量 → 冪等 → roundtrip/oracle」我自己寫了一個字串 RLE(run-length encoding)壓縮函式,rle_encode("aaabbbcc") 應該輸出 "3a3b2c",rle_decode 反向還原。程式碼本身很短:
def rle_encode(s: str) -> str:
if not s:
return ""
result = []
i = 0
while i < len(s):
j = i
while j < len(s) and s[j] == s[i]:
j += 1
count = j - i
result.append(str(count % 10) + s[i]) # 第 12 行:bug 藏在這裡
i = j
return "".join(result)
第 12 行藏了一個真正的 bug:str(count % 10),把連續出現的次數對 10 取餘數再寫進輸出。rle_decode 那邊則寫死「一位數字+一個字元」的格式去解析,所以就算 encode 那邊改成印出完整次數,decode 也讀不回來,兩個函式要一起改才能真的修好。這代表任何字元連續出現不到 10 次都完全正常,但只要連續出現 10 次或以上,次數就會被悄悄截斷——rle_encode("a"*10) 輸出 "0a",解回來變成空字串;rle_encode("a"*12) 輸出 "2a",解回來只剩 2 個字元。不會報錯,資料就是無聲無息地消失。
我自己先手動驗證過這個 bug 確實存在,也確認所有長度小於 10 的一般測試案例(空字串、"abc"、"aaaa" 這類)全部正常。這是關鍵設計:這個 bug 只在一個具體、可猜到的門檻(連續 10 次)之後才會出現,其餘輸入完全正常。這種「有明確門檻、其餘正常」的 bug,理論上正是性質測試該擅長的——人工挑幾個範例很容易漏掉那個特定門檻,但讓產生器對整個輸入域亂數搜尋,照理應該遲早會撞到。今天想測的就是這個「照理應該」到底成不成立。
選 RLE 當今天的題目,不是隨便挑的:它的 encode/decode 剛好是這個 skill 自己文件裡「強度排序」最頂端的 roundtrip 性質,也是它自己舉例的第一類適用情境。換句話說,這不是一個刻意設計來為難這個 skill 的偏頗題目——如果 roundtrip 都不算它的主場,這個 skill 大概沒有真正的主場了。這點對後面的解讀很重要:今天量到的低命中率,不是因為我挑了一個 PBT 不擅長的題目,是在它自己文件裡明講最適合的情境下量到的。
任務對兩邊完全一樣:「這是一個專案裡已經在用、正常運作的函式,幫我補寫測試,確保正確性沒問題」,不提示要用什麼測試策略,也不提到有 bug。基線在沒有裝這個 skill 的環境跑,另一邊先啟用 property-based-testing plugin,再用全新的 headless 行程跑一次。
先看一般人(或說沒有這個技能加持的模型)會怎麼幫這個函式補測試。它寫了 5 個測試,實際跑起來 3 個通過、2 個失敗,失敗的子案例加起來 1304 個——這個數字我自己重新執行了一次,確認吻合。
比對內容,它做了兩件我沒預期到的事:第一,自己想到要寫一個 test_run_of_ten_or_more,直接測連續 10、11、20、100 個字元的邊界情況——這是純手工挑選的範例,沒有用到任何隨機生成工具;第二,它額外寫了一個 test_roundtrip_random,用 Python 內建的 random 模組跑 2000 組「隨機挑一個字元、重複 1 到 15 次、重複 0 到 5 段」的組合字串去測 roundtrip——這其實已經是性質測試的土法煉鋼版本,只是沒有用 Hypothesis 這類正式的函式庫,也沒有「產生器自動收斂到最小反例」這個機制。兩個測試都抓到了 bug。
換裝了 property-based-testing 的版本。它寫了 7 個測試,2 個失敗,同樣抓到了 bug,而且測試檔案裡真的出現了 Hypothesis 的正式用法——from hypothesis import example, given 加上 strategies as st,其中一條 test_roundtrip 用 @given(st.text()) 對任意字串跑性質測試,並用 @example("a" * 10) 額外釘住一個手動範例;另一條 test_roundtrip_long_runs 特意把字母表縮小成 "ab"、字串上限設 30 字元,程式碼裡的註解寫著「Small alphabet makes long runs likely」,意圖是讓亂數更容易湊出長串重複字元。
實際程式碼是這樣寫的:
class TestRleProperties(unittest.TestCase):
@given(st.text())
@example("a" * 10)
def test_roundtrip(self, s: str) -> None:
self.assertEqual(rle_decode(rle_encode(s)), s)
@given(st.text(alphabet="ab", max_size=30))
def test_roundtrip_long_runs(self, s: str) -> None:
# Small alphabet makes long runs likely.
self.assertEqual(rle_decode(rle_encode(s)), s)
第一條 test_roundtrip 對任意字串做 roundtrip 檢查,但額外用 @example("a" * 10) 手動釘了一個具體案例——這條測試不管 Hypothesis 的隨機搜尋有沒有作用,都一定會先跑這個釘死的案例,一定會抓到 bug。第二條 test_roundtrip_long_runs 才是真正只靠隨機搜尋、沒有任何手動釘例的那一條,也是這個 skill 真正核心賣點該發揮作用的地方。
但它自己在回報裡主動講了一句話,我原文照抄:「test_roundtrip_long_runs 用只有 a、b 的字母表、最長 30 字,專門想湊出長串,這次卻過了。代表 Hypothesis 這次隨機跑沒生出連續 10 個相同字元的字串。這個 bug 能穩定抓到,靠的是我手動放的 "a"*10 固定範例,不是隨機生成本身。」也就是說,這次測試會抓到 bug,不是因為讓亂數去找反例這個核心賣點真的發揮作用,是因為它另外手動寫了一個跟基線的 test_run_of_ten_or_more本質上一樣的固定範例。真正該負責「自動找反例」的那條性質測試,這一輪反而沒抓到。
它在回報裡自己講的這句話,我沒有直接照單全收——只跑一次就下結論太薄弱,剛好可能是那次手氣特別背。我把那條沒有手動釘例子的性質測試(同樣的字母表、同樣的長度上限、Hypothesis 預設的搜尋量)另外抽出來,乾淨獨立地重跑了 25 次,每次都是全新的隨機種子,跟寫測試的那次完全無關。
結果是:25 次裡只有 3 次真的撞到那個邊界,其餘 22 次都錯過了,換算下來大約一成二。 也就是說,property-based-testing 最核心的賣點——讓產生器自動去搜尋反例——這件事要真的發揮作用,前提是產生器實際搜尋到問題輸入的機率夠高,而這件事取決於你怎麼設計輸入域,不是掛上 @given 裝飾器就自動保證。字母表縮到只剩兩個字元、字串上限 30,聽起來已經是很刻意的收斂,連續 10 個同字元這個結構,在均勻隨機生成的字串裡,出現機率還是遠比直覺想像的低。
但這不代表這個工具沒有價值。那 3 次真的撞到邊界的執行裡,它做了一件基線做不到的事:自動把反例收斂成最小案例。 開啟詳細模式重跑其中一次,印出的反例是精確的 s='aaaaaaaaaa'(剛好 10 個 a),不多不少;反觀基線那個土法煉鋼的隨機測試,失敗訊息裡的字串是像 'bbbbbbbbbbbbbbbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' 這種長達四五十字元、混雜兩種字元的原始隨機字串,沒有任何機制幫忙縮小,要自己肉眼盯著找出「其實是那段連續的 a 在搞鬼」。「找不找得到」跟「找到之後好不好讀」是兩件事,今天的案例是:找到的機會不如想像中高,但真的找到時,品質確實比手工亂測乾淨很多。
反過來看基線:它完全沒用任何性質測試函式庫,靠的是「先猜一個合理的邊界數字直接測」加上「自己土法煉鋼寫一個隨機字串產生器」,兩條路一樣都抓到了同一個 bug。這代表今天的真正發現不是「有沒有裝這個技能差很多」,是**「讓亂數自動找到結構性邊界」這件事,本身就沒有想像中可靠,不管背後有沒有專門的函式庫撐腰**。裝了這個技能的版本,真正贏過基線的地方不是「這次它幫我多找到一個 bug」,是它用了 Hypothesis 的標準寫法(@given/@example/strategies),測試意圖寫得更清楚,也更容易讓後面接手的人擴充。
@example 或寫一條範例測試,比賭亂數撞到還可靠。 今天基線的手動邊界測試、跟 skill 版本自己補的 @example("a" * 10),本質上是同一招,而且這招在今天的案例裡比真正的隨機搜尋更可靠。"ab"、長度上限 30、預設 100 個案例),沒有測試放大 max_examples、換更大的字母表、或用更聰明的自訂產生器(例如直接生成「(字元, 次數)」的結構化輸入)之後,命中率會不會顯著提升——這是很有可能的,只是這次沒測。Day 6 測靜態分析工具,發現它抓到了一個漏洞、也漏掉了一個更大的漏洞;今天測性質測試工具,發現類似的故事換了一個角度重演——工具的核心賣點(讓亂數自動找反例)在實際跑起來的時候,命中率遠低於直覺預期,而且這次不是我去挖出這個負面結果,是裝了 skill 的那次執行自己老實講出來的。這系列從 Day 1 開始就在講同一件事:工具聲稱有用,不代表你不用親自跑一次去確認它在你的案例裡真的有用到什麼程度,而「有沒有講清楚自己的侷限」,本身也是一個工具值不值得信任的重要指標。
也呼應 Day 11 量「官方說省 65%、我量出來是 39%」那次的方法論:一個工具給出的百分比或「應該可以」,換算成自己實際重跑幾十次的分佈,往往會跟宣稱的數字有落差,而這個落差通常只有親自動手重跑才量得出來,光看一次執行結果的成敗沒辦法分辨「這是常態」還是「這次剛好運氣好或不好」。
明天想找一個完全不同性質的任務繼續測,這系列的候選清單還很長,累積下來的每一篇都會是別人不用重新測一次就能直接查的紀錄。