Anda mengirimkan model machine learning yang memberi label pada tiket dukungan. Test suite Anda berwarna hijau. Setiap pengujian lulus.

Tidak ada satu pun pengujian tersebut yang benar-benar memeriksa apakah label-labelnya benar. Anda tidak mengetahui label yang benar apa. Tidak ada yang tahu. Output “benar” pada dasarnya tidak dapat diketahui untuk input dunia nyata, jadi Anda mengambil jalan pintas dengan memeriksa bahwa function tidak crash, atau bahwa bentuk output sesuai ekspektasi. Itu bukan pengujian. Itu berharap.

Ini adalah oracle problem, dan ini muncul di mana-mana: compiler, simulasi, algoritma optimasi, detektor fraud, dan sistem apa pun di mana ground truth mahal, subjektif, atau tidak mungkin dihitung. Ketika Anda tidak bisa mendefinisikan output yang diharapkan, unit test tradisional gagal total.

Metamorphic testing menawarkan jalan keluar. Alih-alih bertanya “apakah output ini benar?”, Anda bertanya “apakah input terkait menghasilkan output yang memenuhi hubungan yang diketahui?” Jika tidak, kode Anda rusak. Jika ya, Anda mendapat kepercayaan nyata tanpa pernah mengetahui jawaban yang benar.

Apa itu metamorphic testing?

Metamorphic testing diusulkan pada akhir 1990-an oleh Tsong Yueh Chen dan rekan sebagai respons terhadap realitas yang membuat frustrasi: banyak program benar-benar berguna tetapi secara praktis tidak dapat diuji dengan oracle tradisional.

Ide intinya sederhana. Anda mengidentifikasi sebuah metamorphic relation: properti yang harus berlaku antara input dan output dari banyak eksekusi program Anda. Anda jalankan program pada input sumber, ubah input tersebut sesuai dengan relation, jalankan lagi, dan periksa apakah output-output tersebut saling berhubungan seperti yang diprediksi oleh properti.

Tidak diperlukan output yang diharapkan. Tidak diperlukan penanda label manusia. Tidak diperlukan golden dataset.

Pertimbangkan sebuah function yang menemukan jalur terpendek dalam graf berbobot:

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

Untuk graf yang kompleks, menghitung jalur terpendek yang diharapkan secara manual itu membosankan. Tetapi kita mengetahui beberapa metamorphic relations yang harus berlaku:

  1. Monotonicity terhadap bobot edge. Jika Anda meningkatkan bobot satu edge, jalur terpendek tidak boleh menjadi lebih pendek. Jalur tersebut bisa tetap sama (jika edge tersebut tidak berada pada jalur optimal) atau menjadi lebih panjang.

  2. Homogenitas di bawah penskalaan. Jika Anda mengalikan setiap bobot edge dengan konstanta positif, panjang jalur terpendek harus diskalakan dengan konstanta yang sama.

  3. Simetri jalur pada graf tak berarah. Jika graf tak berarah, menukar start dan end harus menghasilkan panjang jalur yang sama.

Ini bukan heuristic. Ini adalah properti matematis. Jika ada satu pun yang gagal, implementasinya salah, tanpa diskusi.

Cara menulis metamorphic test

Berikut tampilan relation monotonicity dalam praktik:

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

Pengujian ini tidak pernah menghitung jalur terpendek yang diharapkan. Pengujian ini tidak perlu melakukannya. Pengujian ini memeriksa bahwa properti struktural berlaku, yang cukup untuk menangkap beragam bug yang mengejutkan: error tanda, kesalahan off-by-one dalam akumulasi bobot, pengurutan priority queue yang salah, dan lainnya.

Relation penskalaan bahkan lebih sederhana untuk diuji:

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

Perhatikan toleransi floating-point. Metamorphic tests tidak kebal terhadap masalah presisi numerik, jadi tulis assertion Anda dengan kehati-hatian yang sama seperti yang Anda gunakan dalam pengujian numerik lainnya.

Di mana pendekatan ini benar-benar membantu

Metamorphic testing bersinar di domain di mana oracle tradisional lemah atau tidak ada.

Machine learning. Anda tidak mengetahui skor sentimen yang tepat untuk ulasan film, tetapi Anda tahu bahwa menambahkan kata “terrible” tidak boleh meningkatkan sentimen positif. Anda tidak mengetahui bounding box yang tepat untuk detektor objek, tetapi Anda tahu bahwa membalik gambar secara horizontal harus membalik koordinat bounding box.

Compiler. Memverifikasi bahwa binary yang dioptimasi menghasilkan output yang tepat sama dengan yang tidak dioptimasi untuk setiap program yang mungkin adalah hal yang tidak mungkin. Tetapi Anda bisa memeriksa bahwa mengompilasi sebuah program, lalu mengompilasinya lagi dengan transformasi no-op (seperti mengganti nama variabel), menghasilkan binary yang secara semantik ekuivalen.

Scientific computing. Anda tidak mengetahui trayektori eksak dari partikel dalam simulasi yang kompleks, tetapi Anda tahu bahwa membalikkan waktu harus membalikkan trayektori. Anda tidak mengetahui energi ground-state eksak dari sebuah molekul, tetapi Anda tahu energi tersebut harus menurun (atau tetap konstan) saat Anda meningkatkan ukuran basis set.

Dalam setiap kasus, wawasannya sama: kebenaran tidak selalu tentang mencocokkan nilai yang diharapkan tunggal. Kadang-kadang kebenaran adalah tentang mempertahankan struktur di berbagai transformasi.

Trade-off dan keterbatasan

Metamorphic testing tidak gratis, dan ini bukan pengganti untuk setiap jenis pengujian lainnya.

Relation bisa tidak lengkap. Sebuah program bisa lulus setiap metamorphic relation yang Anda definisikan dan tetap salah. Jika set relation Anda tidak mencakup kelas bug tertentu, bug tersebut lolos. Ini adalah masalah coverage, dan masalah ini nyata.

Relation bisa salah. Jika Anda salah menegaskan bahwa sebuah properti berlaku padahal tidak, pengujian Anda menjadi pabrik false positive. Saya pernah melihat sebuah tim menegaskan bahwa k-means clustering harus invarian terhadap feature scaling. Padahal tidak. Centroid diskalakan dengan data. Pengujian lulus selama berbulan-bulan karena faktor penskalaan dalam data pengujian kebetulan 1,0. Ketika data nyata tiba, model menurun secara diam-diam sementara pengujian tetap hijau.

Debugging kegagalan lebih sulit. Ketika unit test tradisional gagal, Anda tahu persis output yang diharapkan apa. Ketika metamorphic test gagal, Anda tahu sebuah relation dilanggar, tetapi Anda tetap tidak mengetahui output yang benar. Anda harus menalar mundur dari pelanggaran properti ke bug yang mendasarinya, yang bisa lebih banyak kerjaan.

Pembuatan data pengujian itu penting. Graf acak, kalimat acak, dan gambar acak tidak representatif untuk input nyata. Sebuah metamorphic test pada data sintetis mungkin lulus sementara sistem produksi gagal pada edge case yang generator Anda tidak pernah hasilkan. Gunakan property-based testing library seperti Hypothesis untuk membantu, tetapi tetap skeptis terhadap generator Anda sendiri.

Cara mulai menggunakannya hari ini

Anda tidak membutuhkan framework baru. Anda membutuhkan tiga hal:

  1. Pilih satu function dengan oracle yang lemah. Sebuah method inferensi model, sebuah komputasi geometri, sebuah langkah simulasi. Sesuatu di mana Anda saat ini menguji “tidak crash” dan berharap bisa melakukan lebih.

  2. Brainstorm tiga relation. Tanyakan: transformasi apa yang seharusnya membiarkan output tidak berubah? Transformasi apa yang seharusnya mengubah output secara dapat diprediksi? Pasangan input apa yang seharusnya menghasilkan output yang berhubungan? Tulis, meskipun terasa jelas.

  3. Implementasikan satu relation sebagai pengujian. Jalankan pada input yang dirandomisasi. Jika gagal, Anda menemukan bug atau relation yang salah. Keduanya bernilai.

Untuk contoh k-means yang saya sebutkan sebelumnya, set relation yang benar terlihat seperti ini:

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_)

Pengujian ini akan gagal jika logika clustering menangani sistem koordinat dengan salah, dan pengujian melakukannya tanpa pernah menegaskan centroid yang benar apa.

Ketika Anda tidak bisa mengetahui jawabannya, uji strukturnya

Oracle problem bukan kasus pinggiran dalam pengujian. Oracle problem adalah keadaan default untuk kelas besar perangkat lunak yang berguna. Metamorphic testing tidak menyelesaikannya sepenuhnya, tetapi menggerakkan Anda dari “saya tidak bisa menguji ini” ke “saya bisa menguji properti yang penting.”

Mulailah dengan satu relation, satu function, dan satu bug nyata yang ditangkapnya. Itu cukup untuk membenarkan pendekatan ini. Segala sesuatu yang lain hanyalah menambahkan relation lebih banyak.