Вы выкатываете модель машинного обучения, которая маркирует тикеты поддержки. Ваш test suite зелёный. Каждый тест пройден.

Ни один из этих тестов на самом деле не проверяет, правильны ли метки. Вы не знаете, какие метки правильные. Никто не знает. «Правильный» вывод практически невозможно узнать для реальных входных данных, поэтому вы прибегаете к проверке, что функция не падает, или что форма вывода соответствует ожиданиям. Это не тестирование. Это надежда.

Это проблема oracle, и она возникает повсюду: компиляторы, симуляции, алгоритмы оптимизации, детекторы мошенничества и любая система, где ground truth дорогая, субъективная или невозможна для вычисления. Когда вы не можете определить ожидаемый вывод, традиционные unit-тесты разваливаются.

Metamorphic testing предлагает выход. Вместо вопроса «правильен ли этот вывод?» вы спрашиваете «производят ли связанные входные данные выводы, удовлетворяющие известному соотношению?» Если нет — ваш код сломан. Если да — вы получили реальную уверенность, так и не узнав правильный ответ.

Что такое metamorphic testing?

Metamorphic testing был предложен в конце 1990-х Цонг Юэ Ченом и коллегами в ответ на неприятную реальность: многие программы действительно полезны, но практически нетестируемы с традиционными oracles.

Базовая идея проста. Вы выявляете metamorphic relation: свойство, которое должно выполняться между входными данными и выводами нескольких запусков вашей программы. Вы запускаете программу на исходном входе, трансформируете этот вход согласно соотношению, запускаете снова и проверяете, соотносятся ли выводы друг с другом так, как предсказывает свойство.

Ожидаемый вывод не требуется. Никаких человеческих маркировщиков. Никакого golden dataset.

Рассмотрим функцию, которая находит кратчайший путь во взвешенном графе:

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. Симметрия пути в неориентированных графах. Если граф неориентированный, swapping начала и конца должен давать ту же длину пути.

Это не эвристики. Это математические свойства. Если хотя бы одно из них нарушается — реализация неверна, точка.

Как написать 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

Этот тест никогда не вычисляет ожидаемый кратчайший путь. Ему это не нужно. Он проверяет, что структурное свойство выполняется, чего достаточно, чтобы поймать удивительное разнообразие багов: ошибки знака, off-by-one ошибки в накоплении весов, неправильный порядок priority 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

Обратите внимание на floating-point tolerance. Metamorphic tests не защищены от проблем численной точности, поэтому пишите assertions с той же тщательностью, что и в любом другом численном тесте.

Где этот подход реально помогает

Metamorphic testing блистает в областях, где традиционные oracles слабы или несуществуют.

Машинное обучение. Вы не знаете точного sentiment score для обзора фильма, но знаете, что добавление слова «terrible» не должно повышать позитивный sentiment. Вы не знаете точного bounding box для детектора объектов, но знаете, что горизонтальный flip изображения должен flip-овать координаты bounding box.

Компиляторы. Проверить, что оптимизированный бинарник даёт точно такой же вывод, как неоптимизированный, для каждой возможной программы невозможно. Но можно проверить, что компиляция программы, а затем повторная компиляция с no-op transformation (вроде переименования переменной) даёт семантически эквивалентные бинарники.

Научные вычисления. Вы не знаете точной траектории частицы в сложной симуляции, но знаете, что обращение времени должно обратить траекторию. Вы не знаете точной энергии основного состояния молекулы, но знаете, что она должна уменьшаться (или оставаться постоянной) при увеличении размера basis set.

В каждом случае инсайт один: корректность — это не всегда соответствие единственному ожидаемому значению. Иногда это сохранение структуры при трансформациях.

Компромиссы и ограничения

Metamorphic testing не бесплатен, и он не замена для любого другого вида тестов.

Соотношения могут быть неполными. Программа может пройти каждое metamorphic relation, которое вы определили, и всё равно быть неверной. Если ваш набор соотношений не покрывает конкретный класс багов, этот баг проскальзывает. Это проблема покрытия, и она реальна.

Соотношения могут быть неверными. Если вы ошибочно утверждаете, что свойство выполняется, когда это не так, ваш тест становится фабрикой ложноположительных срабатываний. Однажды я видел команду, которая утверждала, что k-means clustering должен быть инвариантен к feature scaling. Это не так. Центроиды масштабируются вместе с данными. Тест проходил месяцами, потому что scaling factor случайно был 1.0 в тестовых данных. Когда пришли реальные данные, модель тихо деградировала, пока тесты оставались зелёными.

Отладка падений сложнее. Когда падает традиционный unit-тест, вы точно знаете, каким был ожидаемый вывод. Когда падает metamorphic test, вы знаете, что соотношение нарушено, но всё ещё не знаете правильного вывода. Нужно рассуждать в обратном направлении от нарушения свойства к лежащему в основе багу, что может быть больше работы.

Генерация тестовых данных имеет значение. Случайные графы, случайные предложения и случайные изображения не репрезентативны для реальных входных данных. Metamorphic test на синтетических данных может проходить, пока production-система падает на крайних случаях, которые ваш генератор никогда не создавал. Используйте библиотеки property-based testing вроде Hypothesis, но оставайтесь скептичны к своим генераторам.

Как начать использовать это сегодня

Вам не нужен новый фреймворк. Вам нужны три вещи:

  1. Выберите одну функцию со слабым oracle. Метод inference модели, геометрическое вычисление, шаг симуляции. Что-то, где вы сейчас тестируете «не падает ли» и хотели бы сделать больше.

  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 — не крайний случай тестирования. Это состояние по умолчанию для огромного класса полезного ПО. Metamorphic testing не решает её полностью, но переводит вас из «я не могу это протестировать» в «я могу тестировать свойства, которые имеют значение».

Начните с одного соотношения, одной функции и одного реального бага, который оно ловит. Этого достаточно, чтобы оправдать подход. Всё остальное — просто добавление соотношений.