Metamorphic testing 在 GCC 與 LLVM 中發現了 147 個已確認的 bug,在汽車 OEM 使用的商業 ADAS 模擬器中發現了缺陷,並在一名行人被撞身亡的八天前,於一個自駕車感知系統中發現了致命缺陷。這項技術聽起來很學術,但這些 bug 可不是。
問題出在 oracle problem。對許多程式來說,你可以執行輸入,但無法獨立驗證輸出是否正確。一個擁有 10,000 個節點的道路網路,其精確最短路徑是什麼?這個編譯器最佳化是否保留了語義?這個 ML 模型的分類結果到底對不對?你不知道。傳統單元測試在這裡會完全失效,因為當你不知道 expected 應該是什麼時,你沒辦法寫 assertEquals(expected, actual)。
Metamorphic testing 透過完全不檢查輸出來繞過這個問題。它檢查的是輸出之間的關係。
什麼是 metamorphic testing?
Metamorphic testing 是一種技術,你將輸入轉換成一個相關的輸入,讓兩者都通過程式執行,然後斷言兩個輸出遵守一個已知的數學或邏輯關係。這個關係稱為 metamorphic relation。
如果你的程式計算一組數字的平均值,你不需要知道 [4.2, 1.7, 9.3, 2.1] 的精確平均值才能測試它。你只需要知道打亂清單順序應該產生相同的結果,或者將每個元素加倍應該使平均值加倍。這些都是 metamorphic relations。
第一個輸入是 source test case。轉換後的輸入是 follow-up test case。Oracle 就是這個關係本身。
以下是一個具體的 Python 範例:
import random
def compute_average(numbers):
"""Returns the arithmetic mean of a list of numbers."""
if not numbers:
raise ValueError("empty list")
return sum(numbers) / len(numbers)
def test_average_permutation_invariant():
"""MR-1: Shuffling the input should not change the average."""
source = [4.2, 1.7, 9.3, 2.1, 5.6]
follow_up = source.copy()
random.shuffle(follow_up)
source_out = compute_average(source)
follow_up_out = compute_average(follow_up)
assert source_out == follow_up_out, (
f"Permutation MR failed: {source_out} != {follow_up_out}"
)
def test_average_scaling():
"""MR-2: Doubling every element should double the average."""
source = [3.0, 6.0, 9.0]
follow_up = [x * 2 for x in source]
source_out = compute_average(source)
follow_up_out = compute_average(follow_up)
assert follow_up_out == source_out * 2, (
f"Scaling MR failed: {follow_up_out} != {source_out * 2}"
)
def test_average_inclusion():
"""MR-3: Appending the average itself should not decrease the average."""
source = [10.0, 20.0, 30.0]
source_out = compute_average(source)
follow_up = source + [source_out]
follow_up_out = compute_average(follow_up)
assert follow_up_out == source_out, (
f"Inclusion MR failed: {follow_up_out} != {source_out}"
)
if __name__ == "__main__":
test_average_permutation_invariant()
test_average_scaling()
test_average_inclusion()
print("All metamorphic relations passed.")
如果任何一個 relation 失敗,你就找到了一個 bug,而且完全不需要手動計算預期的平均值。這就是核心概念。
在實際生產系統中發現的真實 bug
這項技術並非純理論。以下是 metamorphic testing 在實際生產軟體中發現真實 bug 的記錄案例。
GCC 與 LLVM 中的 147 個 bug
研究人員將 metamorphic testing 應用於 C 編譯器的 compiler optimization pipeline,在 GCC 與 LLVM 中發現了 147 個已確認的 bug。這些不是玩具程式。它們是真實的 miscompilation bug,即正確的 C 程式經過最佳化編譯器處理後,產生了錯誤的機器碼。其中一些 bug 已經存在多年。這些 metamorphic relations 很簡單:如果你手動 inline 一個函式,最佳化後的輸出應該與原始版本行為相同。如果你重新排列獨立的語句,結果不應該改變。編譯器開發者確認並修復了這些 bug。
Google 的 Vulkan shader compiler
Google 的 GraphicsFuzz 團隊將隨機化的 metamorphic testing 投入 Khronos Vulkan Conformance Test Suite 的生產使用。他們生成隨機的 fragment shader,應用保留語義的轉換(例如將表達式包在 identity function 中,或加入 dead code),然後比較不同 compiler 與 GPU 之間的渲染圖像。當兩個理應等價的 shader 產生不同的像素時,他們就發現了 compiler bug。該團隊建立了一整套名為 gfauto 的 pipeline 來縮減、去重複並回報這些案例。他們在轉換、最佳化與驗證 Vulkan shader 的整套工具生態系中發現了 bug,包括已出貨給終端使用者的生產驅動程式。
汽車 OEM 使用的 ADAS 模擬器
一個團隊測試了三個熱門的 ADAS 模擬平台:Simulink、CarMaker 和 51Sim-One Cloud,聚焦於它們的 Lane Keeping Assist System。一般測試案例在三個平台上都通過了,完全沒有問題。接著該團隊應用了幾何 metamorphic relations:將道路場景水平鏡射、旋轉車輛位置、對車道標記套用 affine transformation。輸出應該可以預測地轉換。但它們沒有。三個平台都出現了 bug。MathWorks 與 IPG Automotive 後來確認了這些問題。這些正是車輛軟體上路前用來驗證的同一批平台。
同一類別的 self-driving car 缺陷
在一個最令人警醒的案例中,研究人員將 metamorphic testing 應用於自駕車的物體偵測系統,在 perception pipeline 中發現了一個 bug。該系統在特定轉換後的輸入下,無法正確分類行人。他們回報了這個問題。他們發現的 bug 屬於同一類缺陷,這類缺陷已被認為與致命的 self-driving car 行人碰撞有關。
權衡:relations 是領域專屬的
Metamorphic testing 很強大,但並非沒有代價。困難之處在於找出好的 metamorphic relations。一個壞的 relation 會給你虛假的信心。一個太弱的 relation 抓不到 bug。一個太強的 relation 會因為 floating-point noise 或非決定性而對正確的行為產生誤報。
設計 relations 需要領域知識。對於最短路徑演算法,好的 relations 包括:在無向圖中,A->B 的路徑成本應該等於 B->A;將每條邊的權重加上一個常數,總路徑成本應該增加該常數乘以邊數。對於排序演算法:反轉已排序的清單再排序,應該得到原始排序清單的反轉;輸出中的每個元素都應該以相同頻率出現在輸入中。
你無法在不相關的系統之間重複使用相同的 relations。這就是代價。
浮點運算是另一個陷阱。許多 relations 假設精確相等,但在 IEEE 754 中 0.1 + 0.2 != 0.3。你需要基於容差的比較,而選擇正確的容差本身就是一個問題。太嚴格會產生誤報,太寬鬆會漏掉真正的 bug。
如何將 metamorphic testing 加入你的 codebase
你不需要框架。你需要的是紀律。
從 codebase 中那些沒有 oracle 的函式開始。ML inference、最佳化演算法、幾何計算、統計聚合與模擬程式碼都是候選對象。對每一個,問自己:如果我用特定且可預測的方式改變輸入,輸出必須滿足什麼條件?
每個測試函式只寫一個 metamorphic relation。命名要清楚。在 CI 中與單元測試一起執行。當 relation 失敗時,像對待任何其他測試失敗一樣處理它。
以下是一個稍微更貼近現實的範例,測試一個路徑尋找函式:
import math
def shortest_path_cost(graph, start, end):
"""Returns the cost of the shortest path. Assume implemented."""
pass
def test_shortest_path_undirected_symmetry():
"""MR: In an undirected graph, path cost A->B equals B->A."""
graph = {
'A': [('B', 3.0), ('C', 1.0)],
'B': [('A', 3.0), ('C', 1.0)],
'C': [('A', 1.0), ('B', 1.0)],
}
ab = shortest_path_cost(graph, 'A', 'B')
ba = shortest_path_cost(graph, 'B', 'A')
assert math.isclose(ab, ba, rel_tol=1e-9), f"Symmetry failed: {ab} != {ba}"
def test_shortest_path_subpath():
"""MR: The shortest path cost cannot exceed any specific path's cost."""
graph = {
'A': [('B', 2.0), ('C', 10.0)],
'B': [('C', 2.0)],
'C': [],
}
cost = shortest_path_cost(graph, 'A', 'C')
assert cost <= 10.0, f"Subpath MR failed: {cost} > 10.0"
assert math.isclose(cost, 4.0, rel_tol=1e-9), f"Expected 4.0, got {cost}"
你不是在測試演算法本身。你是在測試你對它的實作。
常見問題
Metamorphic testing 會取代單元測試嗎?
不會。它們是互補的。當你知道預期輸出時,使用單元測試。當你不知道時,使用 metamorphic tests。
我可以把這個用在 ML 模型上嗎?
可以,而且這是最活躍的研究領域之一。像「旋轉一張貓的圖片應該仍然分類為貓」這樣的 relations 就是 metamorphic relations。研究人員已經用這種方法發現了模型可靠性問題與公平性差距。
我怎麼知道我的 metamorphic relation 是正確的?
你不用證明它。你從規格或領域的數學性質來推論。如果你的 relation 本身有 bug,你會得到誤報。從明顯的性質開始,隨著信心增加再加入更多。
那麼 flaky tests 呢?
非決定性系統(機率演算法、並行程式碼、有超時設定的系統)會讓 metamorphic testing 更困難。你可能需要執行多次試驗,或使用統計性的 relations 而非精確相等。
從一個 relation 開始
使用這個不需要博士學位。在你的系統中挑一個函式,因為驗證輸出太難而跳過測試的。寫一個 metamorphic relation。執行它。如果你想深入,GraphicsFuzz 團隊的 gfauto 工具是開源的,而 Segura 等人的 ACM survey 整理了數十個領域的 relations。
這項技術發現了 147 個編譯器 bug、確認了汽車模擬平台中的缺陷,並在自駕車上路前揭露了一類感知系統的失敗。這些 bug 是真實存在的。唯一的問題是,你有沒有在尋找它們。