你上線了一個為支援工單標記分類的機器學習模型。你的測試套件全綠。每個測試都通過了。

那些測試沒有一個真正檢查標籤是否正確。你根本不知道正確的標籤是什麼。沒人知道。對於真實世界的輸入,「正確」的輸出實際上無法得知,所以你只好檢查函式不會崩潰,或者輸出形狀符合預期。這不是測試。這是在祈禱。

這就是 oracle problem,而且它無處不在:編譯器、模擬器、最佳化演算法、詐騙偵測器,以及任何 ground truth 昂貴、主觀或不可能計算的系統。當你無法定義預期輸出時,傳統單元測試就瓦解了。

Metamorphic testing 提供了一條出路。與其問「這個輸出正確嗎?」,不如問「相關的輸入是否產生滿足已知關係的輸出?」如果不符合,你的程式碼就是壞的。如果符合,你就獲得了真正的信心,而且從頭到尾不需要知道正確答案。

什麼是 metamorphic testing?

Metamorphic testing 在 1990 年代末由 Tsong Yueh Chen 與同事們提出,回應了一個令人沮喪的現實:許多程式確實有用,但用傳統 oracle 實際上無法測試。

核心概念很簡單。你找出一個 metamorphic relation:一個應該在程式多次執行的輸入與輸出之間成立的性質。你在來源輸入上執行程式,根據該關係轉換輸入,再執行一次,然後檢查輸出之間的關係是否如該性質所預測。

不需要預期輸出。不需要人工標註者。不需要黃金資料集。

考慮一個在加權圖中尋找最短路徑的函式:

from typing import List, Tuple, Optional

def shortest_path(
    edges: List[Tuple[int, int, float]],
    start: int,
    end: int
) -> Optional[float]:
    """Dijkstra's algorithm. Returns path length or None if unreachable."""
    import heapq

    graph = {}
    for u, v, w in edges:
        graph.setdefault(u, []).append((v, w))

    dist = {start: 0.0}
    heap = [(0.0, start)]

    while heap:
        d, u = heapq.heappop(heap)
        if u == end:
            return d
        if d > dist.get(u, float('inf')):
            continue
        for v, w in graph.get(u, []):
            nd = d + w
            if nd < dist.get(v, float('inf')):
                dist[v] = nd
                heapq.heappush(heap, (nd, v))

    return None

對於複雜的圖,用手算出預期的最短路徑很繁瑣。但我們知道有幾個 metamorphic relations 必須成立:

  1. 關於邊權重的單調性。 如果你增加任何單一邊的權重,最短路徑不應該變短。它可以保持不變(如果那條邊不在最佳路徑上),或者變長。

  2. 縮放下的齊次性。 如果你將每個邊權重乘上一個正常數,最短路徑長度應該以相同常數縮放。

  3. 無向圖上的路徑對稱性。 如果圖是無向的,交換起點與終點應該產生相同的路徑長度。

這些不是啟發式。它們是數學性質。如果有任何一個失敗,實作就是錯的,毫無疑問。

如何撰寫 metamorphic test

以下就是單調性關係在實務中的樣子:

import random

def test_shortest_path_monotonicity():
    # Generate a random connected graph
    nodes = list(range(10))
    edges = []
    for i in range(len(nodes) - 1):
        edges.append((i, i + 1, random.uniform(1.0, 10.0)))
    # Add some random cross edges
    for _ in range(10):
        u, v = random.sample(nodes, 2)
        edges.append((u, v, random.uniform(1.0, 10.0)))

    start, end = 0, 9
    original = shortest_path(edges, start, end)
    assert original is not None

    # Increase the weight of one arbitrary edge
    idx = random.randrange(len(edges))
    u, v, w = edges[idx]
    modified_edges = list(edges)
    modified_edges[idx] = (u, v, w + 5.0)

    modified = shortest_path(modified_edges, start, end)
    assert modified is not None
    assert modified >= original

這個測試從未計算過預期的最短路徑。它不需要。它檢查一個結構性性質是否成立,而這就足以抓到令人驚訝的各種缺陷:正負號錯誤、權重累積的差一錯誤、錯誤的優先queue排序,以及更多。

縮放關係甚至更容易測試:

def test_shortest_path_scaling():
    nodes = list(range(8))
    edges = []
    for i in range(len(nodes) - 1):
        edges.append((i, i + 1, random.uniform(2.0, 5.0)))

    start, end = 0, 7
    original = shortest_path(edges, start, end)

    factor = 3.5
    scaled_edges = [(u, v, w * factor) for u, v, w in edges]
    scaled = shortest_path(scaled_edges, start, end)

    assert abs(scaled - original * factor) < 1e-9

注意浮點數容差。Metamorphic tests 對數值精度問題同樣無法免疫,所以撰寫斷言時要拿出與任何其他數值測試同樣的謹慎。

這個方法真正有用的領域

Metamorphic testing 在傳統 oracle 薄弱或不存在的地方大放異彩。

機器學習。 你不知道電影評論的確切情緒分數,但你知道加入「terrible」這個詞不應該提高正面情緒。你不知道物件偵測器的確切邊界框,但你知道水平翻轉影像應該翻轉邊界框座標。

編譯器。 驗證最佳化後的二進位檔對每個可能的程式都產生與未最佳化版本完全相同的輸出是不可能的。但你可以檢查:編譯一個程式,然後用一個無操作轉換(例如重新命名變數)再編譯一次,是否產生語意等價的二進位檔。

科學計算。 你不知道複雜模擬中粒子的確切軌跡,但你知道反轉時間應該反轉軌跡。你不知道分子的確切基態能量,但你知道隨著基底集合大小增加,它應該下降(或保持不變)。

在每個案例中,洞見都一樣:正確性不一定在於匹配單一預期值。有時候它是在轉換之間保持結構。

取捨與限制

Metamorphic testing 不是免費的,也不能取代所有其他種類的測試。

關係可能不完整。 一個程式可以通過你定義的每一個 metamorphic relation 卻仍然是錯的。如果你的關係集合沒有涵蓋某個缺陷類別,那個缺陷就會溜過去。這就是涵蓋範圍問題,而且它是真實的。

關係本身可能是錯的。 如果你錯誤地斷言某個性質成立,而實際上並不成立,你的測試就會變成假陽性工廠。我曾看過一個團隊斷言 k-means 聚類應該對特徵縮放不變。事實並非如此。中心點會隨資料縮放。測試通過了好幾個月,只因為測試資料中的縮放因子剛好是 1.0。真實資料進來後,模型靜默劣化,而測試依然全綠。

除錯失敗更困難。 當傳統單元測試失敗時,你確切知道預期輸出是什麼。當 metamorphic test 失敗時,你知道某個關係被違反了,但你仍然不知道正確輸出是什麼。你必須從性質違反往回推論到底層缺陷,這可能需要更多功夫。

測試資料產生很重要。 隨機圖、隨機句子與隨機影像並不代表真實輸入。在合成資料上通過的 metamorphic test 可能在生產系統中,於你的產生器從未產生的邊界情況下失敗。使用像 Hypothesis 這樣的 property-based testing 函式庫來協助,但對你自己的產生器保持懷疑。

如何今天就開始使用

你不需要新的框架。你只需要三樣東西:

  1. 挑選一個帶有薄弱 oracle 的函式。 模型推論方法、幾何計算、模擬步驟。某個你目前只測試「它不會崩潰」並希望可以做更多的東西。

  2. 腦力激盪三個關係。 問自己:哪些轉換應該讓輸出不變?哪些轉換應該以可預測的方式改變輸出?哪些輸入對應該產生相關的輸出?把它們寫下來,即使它們看起來很顯而易見。

  3. 將其中一個關係實作成測試。 在隨機化輸入上執行它。如果失敗,你找到了一個缺陷或一個錯誤的關係。兩者都有價值。

對於我前面提到的 k-means 範例,正確的關係集合長這樣:

import numpy as np
from sklearn.cluster import KMeans

def test_kmeans_translation_invariance():
    X = np.random.rand(100, 3)
    shift = np.array([10.0, -5.0, 2.0])

    km1 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
    km2 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X + shift)

    # Centroids should differ by exactly the shift vector
    np.testing.assert_allclose(km1.cluster_centers_ + shift, km2.cluster_centers_)

這個測試會在聚類邏輯錯誤處理座標系統時失敗,而且過程中從未斷言正確的中心點應該是什麼。

當你無法知道答案時,就測試結構

Oracle problem 不是測試的邊緣案例。它是大量實用軟體的預設狀態。Metamorphic testing 沒有完全解決它,但它讓你從「我無法測試這個」進展到「我可以測試重要的性質」。

從一個關係、一個函式,以及它抓到的一個真實缺陷開始。這就足夠證明這個方法的價值。其他的一切都只是增加更多關係。