你的單元測試只覆蓋了你想得起來的案例
你寫了一個 reverse 函式。你用 [1, 2, 3] 和 [4, 5, 6, 7] 測試它。通過了。你發布出去。
有個使用者傳入了一個單元素的 slice。你的函式把它遺漏了。他們開了一個 issue。你盯著測試檔案,納悶自己怎麼會漏掉這麼明顯的東西。
你之所以漏掉,是因為範例導向的測試只能抓到你預期中的 bug。測試套件裡的每一個 assert_eq! 都是在猜測失敗可能藏在哪裡。Property-based testing 用隨機資料與數學不變條件取代了那些猜測。它會找到單元素 bug,也會找到空向量 bug、整數溢位邊界情況,以及你根本不知道存在的 Unicode 角落案例。
用隨機輸入與不變條件取代範例與預期結果
Property-based testing 翻轉了劇本。你不再選擇輸入並斷言確切的輸出,而是定義一個對所有輸入都必須成立的 property,然後測試框架產生數百個隨機輸入來嘗試打破它。
最經典的例子是反轉列表。Property 不是「[1, 2, 3] 反轉後是 [3, 2, 1]」。Property 是「把列表反轉兩次會回到原本的列表」。這個 invariant 對所有可能的列表都成立,所以框架會不斷餵隨機列表給它,直到出錯或達到迭代上限。
Rust 有兩個成熟的函式庫可以處理這件事。quickcheck 是第一個流行的選項,從 Haskell 移植過來。proptest 是較新的方案,帶有一個決定性的功能:自動 shrinking。當 proptest 發現失敗的輸入時,它不會只印出一個一百個元素的向量就讓你自己 debug。它會簡化輸入,直到找到最小的 counterexample。這就是「看著測試失敗的輸出」和「真正理解 bug」之間的差別。
Shrinking 如何找出真正的 Bug
Shrinking 是其中的秘訣。假設 proptest 產生了一個包含 47 個整數的隨機向量,而你的 property 失敗了。Bug 不在第 47 個元素。Bug 很可能跟空向量、正負號變化,或重複值有關。
proptest 知道這一點。它會嘗試移除元素,嘗試將數值歸零,嘗試讓向量越來越小。如果更小的輸入仍然讓測試失敗,它就繼續縮減。當所有更簡單的變化都通過時,它就停止。結果就是 minimal counterexample。
這不是錦上添花的功能。這是 property-based testing 在實務上可用的原因。沒有 shrinking,你就得回去在一堆乾草裡找針。有了它,你拿到的是那根針。
一個真實的 Bug,一個真實的測試
這裡有個帶著微妙 bug 的 reverse 函式。看看你能不能比測試更早發現它。
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
let mut rev = Vec::with_capacity(xs.len());
for i in 1..xs.len() {
rev.push(xs[xs.len() - i].clone());
}
rev
}
這個 range 從 1 開始而不是 0。對於任何非空列表,第一個元素會被默默丟棄。一個用 [1, 2, 3] 的單元測試可能會通過,如果作者只檢查了輸出看起來有反轉,而沒有確認長度是否正確。但 property 會立刻抓到這個問題。
將 proptest 加入你的 Cargo.toml:
[dev-dependencies]
proptest = "1.6"
然後寫下 property:
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
let rev = reverse(&xs);
let rev_rev = reverse(&rev);
prop_assert_eq!(xs, rev_rev);
}
}
執行 cargo test,輸出說明了一切:
thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
left: `[0]`,
right: `[]`'
proptest 將一個失敗的隨機向量縮減到只剩單一元素 [0]。第一次呼叫回傳 []。第二次呼叫也回傳 []。Property 要求 [] == [0],這當然失敗。一旦輸入被縮到最小,bug 就顯而易見了。
你也可以直接測試結構上的 property:
proptest! {
#[test]
fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
prop_assert_eq!(xs.len(), reverse(&xs).len());
}
}
這會用同樣的最小輸入失敗,但給出更清楚的錯誤:輸出長度是 0,不是 1。
為什麼你仍然需要一般的單元測試
Property-based testing 不是用來取代範例導向測試的。它是互補的。
單元測試記錄了意圖。當新進開發者看到 reverse(&[1, 2, 3]) 應該等於 [3, 2, 1] 時,他們就了解這個函式應該做什麼。Property test 說的是「這個 invariant 成立」,但它不會告訴你正常情況下的輸出長什麼樣子。
Property 也比範例更難寫。State machines、side effects 和 I/O 無法乾淨地對應到 pure functions。proptest 有針對複雜型別的 strategies,但要把它們接起來需要比幾個 assert_eq! 更多的思考。
還有時間成本。一個 property test 可能會跑上千次迭代。在大型程式碼庫中,這會累積起來。你應該在 CI 中跑 property tests,而不是在開發過程中每次按鍵都跑。
隨機測試仍然可以重現
隨機性讓人不安。如果測試偶爾才失敗,到底是程式碼有問題還是測試有問題?
proptest 透過持久化失敗的 seed 來處理這個問題。當 property 失敗時,它會將 seed 和縮減後的輸入存到 proptest-regressions/ 的檔案裡。下一次執行時,它會先重播那個確切的案例,然後再產生新的隨機資料。Flaky 的 property test 幾乎總是有 bug 的 property,而不是框架本身不穩定。
如果你需要嚴格的確定性,可以在測試設定中固定 seed。大多數團隊不會這麼做。Regression 檔案就夠了。
Property 測試在什麼地方值得投入
當你的程式碼有明確的數學 invariant 且輸入空間很大時,property-based testing 值得額外的開銷。
排序是教科書級的例子。Properties 很簡單:輸出是有序的、是輸入的排列組合、長度相同。一個有 bug 的排序函式可能通過手寫的範例,但在帶有重複元素的隨機資料上失敗。
Parser 和 serializer 是另一個絕佳場景。Round-trip property 像是 parse(serialize(x)) == x 可以抓到只在特定位元組序列出現的編碼 bug。
任何涉及算術、集合或狀態轉換的東西都能受益。涉及外部 API、嚴格時機或人類判斷的則不適合。
從一個 Property 開始
你不需要重寫整個測試套件。選一個有明確 invariant 的 pure function。把 proptest 加到 dev-dependencies。寫一個 property。執行它,看它通過,然後故意引入一個微妙的 bug,看著它在幾秒內把失敗縮減成你可以輕鬆 debug 的東西。
一旦你親眼看到隨機測試找到一個你永遠不會寫範例去覆蓋的 bug,你就會更常使用 property。不是每個測試都用。只用在那重要的測試上。