Differential Testing 到底給你什麼
你可以信任 differential testing,即使沒有形式化證明——但前提是你必須清楚知道它會在什麼地方崩潰。
它的弱點叫做 common-mode failure。當一個規格的每個實作都做出同樣的錯誤假設時,它們全部一致通過,你的 test harness 就會標為通過。N-version programming 無法保護你免於一份爛規格。
Differential testing 的運作方式是:針對同一個規格,執行多個彼此獨立的實作,並餵給它們相同的輸入。如果輸出不一致,至少有一個有 bug。如果一致,就暫時視為沒問題。
這很強大,因為它消除了對 test oracle 的需求。Oracle 是一種真理來源,知道每個輸入的正確答案。對於複雜系統來說,oracle 往往比系統本身還難建造。稅務引擎、物理模擬、protocol decoder——在你能證明每個輸出應該是什麼之前,早就已經可以測試它們的一致性了。
但這種「暫時」很重要。一致只證明了相容性,並不證明正確性。
為什麼一致不等於正確
那種大家在課堂上都學過、實務上卻總是忘掉的失效模式,就是 common-mode fault。當錯誤源自規格本身,或是所有實作團隊獨立做出的共同假設時,每個版本都會產生同樣的錯誤答案。
規格不必明顯有錯。它只需要在某個邊界條件上夠模糊,而人腦碰巧都用同一種方式解讀。
想像一個規格:寫一個函式,根據頂點列表計算簡單多邊形的面積。規格給了 shoelace formula,但完全沒提頂點順序。
三個團隊各自實作。三個都假設是逆時針順序,因為範例圖就是這樣畫的。順時針輸入套用原始公式會得到負面積。三個實作都默默用 abs() 包起來,因為面積必須是正的。它們在每個測試案例上都一致。
但規格從沒說順時針是無效的。這些實作彼此相容,卻因為遺漏而錯誤。Differential testing 會讓它們全部綠燈通過。
三個實作,一份模糊的規格
這裡有個可以實際執行的例子。規格說:「解析一個 duration 字串,回傳總秒數。Duration 由一個或多個元件組成。每個元件是一個正整數後接一個單位字母:h 代表小時、m 代表分鐘、s 代表秒。」
三個團隊拿到這份規格,各自寫了 parser。
import re
def parse_duration_a(s):
"""Team A: regex approach."""
if not isinstance(s, str):
raise TypeError("input must be a string")
m = re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?", s)
if not m or not any(m.groups()):
raise ValueError(f"invalid duration: {s}")
h, mn, sec = (int(x or 0) for x in m.groups())
return h * 3600 + mn * 60 + sec
def parse_duration_b(s):
"""Team B: left-to-right scanner."""
if not isinstance(s, str):
raise TypeError("input must be a string")
total = 0
i = 0
while i < len(s):
j = i
while j < len(s) and s[j].isdigit():
j += 1
if j == i:
raise ValueError(f"expected number at position {i}")
num = int(s[i:j])
if j >= len(s):
raise ValueError(f"missing unit after {num}")
unit = s[j]
if unit == 'h':
total += num * 3600
elif unit == 'm':
total += num * 60
elif unit == 's':
total += num
else:
raise ValueError(f"invalid unit: {unit}")
i = j + 1
return total
def parse_duration_c(s):
"""Team C: state machine with duplicate detection."""
if not isinstance(s, str):
raise TypeError("input must be a string")
total = 0
seen = set()
i = 0
while i < len(s):
j = i
while j < len(s) and s[j].isdigit():
j += 1
if j == i:
raise ValueError("expected number")
num = int(s[i:j])
if j >= len(s):
raise ValueError("missing unit")
unit = s[j]
if unit in seen:
raise ValueError(f"duplicate unit: {unit}")
seen.add(unit)
i = j + 1
if unit == 'h':
total += num * 3600
elif unit == 'm':
total += num * 60
elif unit == 's':
total += num
else:
raise ValueError(f"invalid unit: {unit}")
return total
現在我們執行一個 differential test harness,把同樣的輸入餵給三個實作,標記不一致的地方。
def differential_test(implementations, inputs):
for case in inputs:
results = []
errors = []
for impl in implementations:
try:
results.append(impl(case))
except Exception as e:
errors.append(type(e).__name__)
if errors:
if len(errors) == len(implementations) and len(set(errors)) == 1:
print(f"ALL ERROR on {case!r}: {errors[0]}")
else:
print(f"MIXED on {case!r}: results={results}, errors={errors}")
else:
if len(set(results)) == 1:
print(f"AGREE on {case!r}: {results[0]}")
else:
print(f"DISAGREE on {case!r}: {results}")
IMPLS = [parse_duration_a, parse_duration_b, parse_duration_c]
CASES = [
"1h30m", # normal
"90m", # single unit
"30m1h", # out of order
"1h2h", # duplicate unit
"0h", # zero is not positive
"1.5h", # decimal
"1H", # wrong case
]
differential_test(IMPLS, CASES)
執行結果:
AGREE on '1h30m': 5400
AGREE on '90m': 5400
MIXED on '30m1h': results=[5400, 5400], errors=['ValueError']
MIXED on '1h2h': results=[10800], errors=['ValueError', 'ValueError']
AGREE on '0h': 0
ALL ERROR on '1.5h': ValueError
ALL ERROR on '1H': ValueError
這個 harness 確實抓到了真正的問題。在 30m1h 上,Team A 的 regex 拒絕了順序錯誤的輸入,而 Teams B 和 C 接受了。在 1h2h 上,Team B 的 scanner 默默把兩個小時加起來,而 Team C 的重複偵測則拋出錯誤。這些正是 differential testing 應該找到的 bug。
但看看 0h。規格說的是「正整數」。零不是正數。三個實作都接受了它,因為沒有任何團隊為一個聽起來很明顯、卻沒有被強制執行的需求寫驗證。它們一致,所以測試通過。這是一個藏在明處的 common-mode failure。
1H 也發生同樣的事。三個都拒絕了,因為規格用的是小寫單位。但如果規格的本意是大小寫不敏感,那每個實作都是錯的,而且一起錯。
如何讓 Differential Testing 比較不會出錯
你無法完全消除 common-mode failure,除非有形式化證明。但你可以讓它們比較不容易發生。
讓實作策略多樣化,不只是人員不同。 如果每個團隊都用同一本教科書的同一個演算法,你並沒有建立多樣性,你只是建立了延遲。強迫一個團隊用 state machine,另一個用 parser generator,第三個用遞迴。不同的演算法會在不同的輸入上失效。
使用不同的程式語言。 共享的標準函式庫 bug 是經典的 common-mode failure。如果每個實作都用同一個 JSON parser 或同一個浮點數學函式庫,它們就共享了它的 bug。
加入一個對抗性的 oracle。 指派一個人專門去找規格模糊的地方。他的工作是讓實作們不一致。那些能讓它們分歧的輸入,是你能寫出的最有價值的測試。
積極進行 fuzzing。 在少數幾個手挑範例上達成一致,證據力很弱。在一百萬個隨機產生的輸入上達成一致,證據力就強多了。Fuzzing 能發現所有團隊都沒考慮到的輸入角落。
測試規格本身。 針對「正整數」這類需求寫明確的負面測試,確認至少有一個實作會拒絕。如果三個實作都接受了無效輸入,你的規格需要收緊,而不是你的程式碼。
Differential Testing 什麼時候夠用
Differential testing 不是形式化驗證的替代品。它是一個過濾器。它能在你投入證明之前,便宜又早期地抓到實作上的 bug。
問題不在於你能不能信任它。問題在於你能信任它做什麼。你可以信任它找到不一致。但你不能信任它在規格錯誤的情況下找到普遍一致。
如果你在建構安全關鍵軟體,differential testing 只是初步步驟。執行它、修復不一致,然後把規格丟進 model checker 或 proof assistant。如果你在建構 web service,differential testing 對一個 feature branch 來說可能已經夠有信心了。證明的投入程度應該與風險成正比。
如果你今天就在執行 N-version test suite,多加一個測試案例。找一個規格沒有清楚定義的輸入。丟進你的實作們。如果它們全部一致,那你找到的不是好測試,而是一個規格的漏洞。
會讓你出事的 bug,不是實作們不一致的那些。而是它們因為錯誤的理由而一致的那些。
常見問題
什麼是 differential testing?
Differential testing 是一種技術,針對同一個規格執行多個獨立實作,並餵給它們相同的輸入,然後比較輸出。不一致的地方就能揭露 bug,而且不需要事先為每個輸入準備正確答案。
什麼是軟體中的 common-mode failure?
Common-mode failure 發生在多個獨立元件因為同一個根本原因而在同一個輸入上失效。在 N-version programming 中,這通常發生在規格模糊,而每個團隊都用同樣方式解讀的時候。
Differential testing 和 property-based testing 有什麼不同?
Property-based testing 檢查輸出是否滿足通用規則,例如「排序列表不會改變長度」。Differential testing 檢查多個實作是否產生相同輸出。這兩種技術互補。Property-based testing 找到邏輯錯誤。Differential testing 找到不一致。
我可以用 LLM 來產生 diverse implementations 做 differential testing 嗎?
可以,但要小心。在重疊語料上訓練的模型傾向於產生具有相關失效模式的程式碼。近期研究顯示,AI 產生元件之間的 co-error rate 介於 15% 到 30%。如果你使用 LLM,要在 prompt 中要求真正不同的演算法和語言。一堆語義上完全相同的改寫,並不是多樣性。