你為定價引擎寫了十二個 metamorphic relations。每個測試都通過。你對自己的涵蓋率感覺良好。
然後顧客回報大量折扣計算反了。你檢查你的關係套件。沒有任何一個測試失敗。你有加法一致性、單調性與idempotency的關係。沒有任何一個抓到折扣乘數的正負號錯誤。
這是 metamorphic testing 不為人知的真相:有關係不等於有有用的關係。一個弱的 metamorphic relation 比完全沒有測試還糟,因為它在你的程式碼其實有錯時,讓你相信它是對的。
什麼讓關係「好」?
一個好的 metamorphic relation 具有高缺陷偵測能力。它抓到程式設計師實際會寫出來的真實缺陷。其他的都只是額外負擔。
經典範例是用一個帶有差一錯誤的平均值函式來測試:
def buggy_mean(values):
"""Compute the arithmetic mean."""
return sum(values) / (len(values) - 1) # bug: off-by-one in denominator
這看起來很合理,如果你習慣樣本變異數公式的話。它也是錯的。以下是四個人們常為平均值函式寫的關係,以及每一個實際能抓到什麼:
-
有界性:平均值落在最小值與最大值之間。弱。這個有缺陷的平均值對大多數輸入仍然滿足這一點。
-
常數的idempotency:
mean([c] * n) == c。中等。它能對常數列表抓到缺陷,但隨機資料很少觸發失敗。 -
平移不變性:
mean([x + c for x in values]) == mean(values) + c。強。這個有缺陷的分母對幾乎所有非空輸入都會破壞這一點。 -
縮放:
mean([x * k for x in values]) == mean(values) * k。強。理由相同。這個差一錯誤在零個有意義的情況下能通過縮放測試。
如果你的測試套件只檢查了有界性與常數idempotency,那個差一錯誤就會溜進生產環境。你會有 metamorphic tests。但你沒有缺陷偵測能力。
強關係 vs. 弱關係
強關係與弱關係的差異不在於它聽起來多聰明。而在於它消滅了多少缺陷類別。
弱關係檢查的是一個大多數錯誤實作反正都無意間滿足的性質。有界性就是最佳範例。大多數算術缺陷會保持有界性,因為加法與乘法不會憑空產生輸入範圍之外的數值。一個在錯誤程式碼上仍然通過的關係只是在演戲。
強關係編碼了一個錯誤實作會違反的結構性限制。平移不變性很強,因為它透過精確相等,將輸入轉換與輸出轉換綁在一起。沒有迴旋餘地。
你可以正式衡量這一點。在 metamorphic testing 研究中,relation subsumption 的意思是 relation A 能偵測到 relation B 能偵測到的每一個缺陷,再加上一些。如果 A subsumes B,那麼 B 就是多餘的。你應該保留 A 並刪除 B。
實務上,你不需要正式證明。你需要的是直覺:如果你在刻意引入一個看似合理的缺陷後,某個關係仍然通過,那它就是弱的。扔掉它。
好的關係涵蓋不同的缺陷領域
一個強關係不夠。單一關係只抓到一類錯誤。真實程式包含多種獨立的缺陷類型,而你的關係集合需要涵蓋它們。
考慮一個排序函式。以下是依它們能抓到的東西排序的關係:
排列:輸出包含與輸入完全相同的元素。抓到遺漏/重複缺陷。漏掉排序缺陷。
順序:輸出是非遞減的。抓到比較缺陷。漏掉排列缺陷。
idempotency:sort(sort(x)) == sort(x)。只抓到真正壞掉、破壞已排序狀態的實作。幾乎沒用。
穩定性:如果你將每個元素與其原始索引配對,相等鍵值保持輸入順序。抓到使用 >= 而非 > 的比較運算子。
子結構:先排序前綴再排序完整列表,應該在前綴順序上達成一致。抓到過早終止缺陷。
一個只有排列與idempotency的測試套件會漏掉一個總是回傳 [1, 2, 3] 的排序。一個有排列與順序的套件會抓到那個缺陷。再加上穩定性,你也能抓到不穩定排序。
重點不是盡可能收集更多關係。重點是涵蓋獨立的失敗模式。兩個抓到相同缺陷的關係,不如一個抓到不同缺陷的關係。
取捨:越強的關係越難找
團隊會寫弱關係是有原因的。強關係需要領域知識。你必須充分理解問題的數學結構,才能將一個非顯而易見的不變量編碼進去。
對於平均值函式,任何有統計學背景的人都會覺得平移不變性顯而易見。對於粒子模擬,對等的關係可能需要知道哈密頓動力學保持相空間體積。不是每個團隊手邊都有這種專業知識。
另一個代價是除錯。當強關係失敗時,違規告訴你某個結構性性質被破壞了,但缺陷可能藏在導致該性質的整條推論鏈的任何地方。一個像「輸出長度等於輸入長度」的弱關係只以一種方式失敗。一個像「平移訊號的傅立葉轉換會獲得線性相位項」的強關係會以一百種方式失敗,而追蹤哪一個才是你的缺陷需要更長時間。
這就是核心張力。弱關係容易寫、容易除錯、但大多沒用。強關係難寫、難除錯、但真的能找缺陷。沒有免費的午餐。
如何評估 metamorphic relation
在把一個關係加入測試套件之前,讓它通過三項檢驗:
刻意缺陷測試。 在你的實作中引入一個看似真實的缺陷。這個關係會失敗嗎?如果不會,這個關係就沒有發揮作用。試試正負號錯誤、差一錯誤、交換參數、遺漏邊界條件。這些都是生產環境中會發生的缺陷。你的關係應該抓到它們。
獨立性測試。 看看你現有的關係。有任何一個會抓到同一個缺陷嗎?如果有,這個新關係就是多餘的。多餘不是安全。它是維護負擔卻沒有邊際效益。
可證偽性測試。 你能想像一個看似合理的錯誤實作滿足這個關係嗎?如果你能在三十秒內畫出一個,這個關係就太弱了。好的關係應該感覺像一個緊密的限制,而不是模糊的建議。
以下是在程式碼中對平均值函式實際操作的樣子:
import random
def mean(values):
return sum(values) / len(values)
def test_translation_invariance():
values = [random.uniform(-100, 100) for _ in range(20)]
c = 5.5
shifted = [x + c for x in values]
assert mean(shifted) == mean(values) + c
def test_scaling():
values = [random.uniform(-50, 50) for _ in range(20)]
k = 3.0
scaled = [x * k for x in values]
assert mean(scaled) == mean(values) * k
現在引入差一錯誤。把 len(values) 改成 len(values) - 1。執行兩個測試。平移不變性立即失敗。縮放立即失敗。有界性很可能通過。
這就是值得佔有你套件一席之地的關係,與只是佔行數的關係之間的差別。
從缺陷類別開始,而非性質
大多數團隊會犯的錯誤是先腦力激盪性質。他們問:「這個函式有什麼不變量?」這會產生弱關係,因為不變量容易陳述但難以違反。
相反地,從缺陷類別開始。問:「一個疲憊的程式設計師會在這個函式裡寫出什麼缺陷?」然後找出能抓到那些缺陷的關係。
對於一個幾何距離函式,可能的缺陷是正負號錯誤、單位混淆與維度不匹配。一個檢查距離為非負數的關係能抓到正負號錯誤。一個檢查座標轉換下縮放行為的關係能抓到單位混淆。一個檢查三角不等式的關係能抓到維度亂象。
如果你說不出某個關係能抓到什麼缺陷,你就不需要那個關係。
Relations 是稀缺資源,請謹慎使用
Metamorphic testing 與涵蓋率指標無關。它與信心有關。一個能抓到真實缺陷的強關係,價值高過二十個在錯誤程式碼上仍然通過的弱關係。
審查你現有的 metamorphic tests。引入一個缺陷。看看什麼會失敗。刪除不會失敗的。然後針對你真正擔心的每一個缺陷類別各加一個關係。這才是一個物有所值的測試套件。