Проблема Оракула

Традиционное тестирование ПО полагается на тестовые оракулы—механизмы для определения, прошел тест или провалился. Для калькулятора оракул прямолинеен: add(2, 3) должно вернуть 5. Но что происходит, когда вы не знаете ожидаемый выход?

Рассмотрим тестирование модели машинного обучения, которая рекомендует фильмы. Какова “правильная” рекомендация для данного пользователя? Или система прогноза погоды—какова точная температура прогноза на следующий вторник? Или оптимизация компилятора—как вы можете проверить, что оптимизированный код функционально эквивалентен оригиналу?

Эти сценарии страдают от проблемы оракула: отсутствия надежного механизма для проверки корректности. Метаморфическое тестирование предлагает элегантное решение, фокусируясь на отношениях между входами и выходами, а не на абсолютной корректности.

Что Такое Метаморфическое Тестирование?

Метаморфическое тестирование валидирует ПО, проверяя метаморфические отношения (MRs)—свойства, которые должны сохраняться через разные выполнения программы. Вместо вопроса “Корректен ли этот выход?”, метаморфическое тестирование спрашивает “Согласуется ли отношение между этими выходами с ожидаемым поведением?”

Центральная Концепция

Метаморфическое отношение определяет, как изменения во входе должны влиять на выход. Например:

MR Поисковика: Если запрос Q возвращает результаты R, то запрос “Q Q” (дублированный запрос) должен вернуть те же результаты R.

MR Тригонометрической Функции: Для функции sin(x), мы знаем, что sin(x + 2π) = sin(x) для любого x. Мы можем протестировать это, не зная точное значение sin(x).

Блестящий инсайт: даже не зная, чему равно sin(0.7) точно, мы можем проверить, что sin(0.7) == sin(0.7 + 2π). Если это отношение нарушено, мы нашли баг—без необходимости в тестовом оракуле.

Метаморфические Отношения: Типы и Примеры

Отношения Идентичности

Выход должен оставаться неизменным при определенных преобразованиях входа.

Пример: Обработка Изображений

# Метаморфическое Отношение: Двойная ротация на 180° = 360° = оригинал
def test_image_rotation_identity():
    original_image = load_image("test.jpg")

    rotated_180 = rotate(original_image, 180)
    rotated_360 = rotate(rotated_180, 180)

    assert images_equal(original_image, rotated_360), \
        "MR нарушена: ротация на 360° должна вернуть оригинальное изображение"

Пример: Шифрование

# MR: Зашифровать затем расшифровать должно вернуть оригинал
def test_encryption_identity():
    original_data = "чувствительная информация"
    key = generate_key()

    encrypted = encrypt(original_data, key)
    decrypted = decrypt(encrypted, key)

    assert original_data == decrypted, \
        "MR нарушена: decrypt(encrypt(data)) должно равняться data"

Инвариантность Перестановки

Выход не должен зависеть от перестановки порядка входа.

Пример: Операции Множеств

# MR: Объединение множеств коммутативно
def test_set_union_commutativity():
    set_a = {1, 2, 3}
    set_b = {3, 4, 5}

    result_ab = set_a.union(set_b)
    result_ba = set_b.union(set_a)

    assert result_ab == result_ba, \
        "MR нарушена: A∪B должно равняться B∪A"

Пример: Система Рекомендаций

# MR: Порядок предпочтений пользователя не должен влиять на распределение жанров
def test_recommendation_permutation_invariance():
    user_preferences = ["sci-fi", "thriller", "drama"]
    recommendations_1 = get_recommendations(user_preferences)

    shuffled_prefs = ["drama", "sci-fi", "thriller"]
    recommendations_2 = get_recommendations(shuffled_prefs)

    # Распределение жанров должно быть похожим
    assert genre_similarity(recommendations_1, recommendations_2) > 0.8, \
        "MR нарушена: порядок предпочтений значительно влияет на рекомендации"

Отношения Монотонности

Выход изменяется монотонно с изменениями входа.

Пример: Релевантность Поиска

# MR: Добавление релевантных терминов должно увеличивать скоры релевантности
def test_search_relevance_monotonicity():
    query_basic = "python programming"
    results_basic = search(query_basic)

    query_enhanced = "python programming tutorial"
    results_enhanced = search(query_enhanced)

    # Топ результат для расширенного запроса должен иметь более высокую релевантность
    # к туториалам по программированию, чем базовый запрос
    assert relevance_score(results_enhanced[0], "tutorial") >= \
           relevance_score(results_basic[0], "tutorial"), \
        "MR нарушена: Добавление релевантного термина не увеличило релевантность"

Отношения Эквивалентности

Разные входы должны производить эквивалентные выходы.

Пример: Оптимизация Компилятора

# MR: Оптимизированный код должен производить те же результаты, что и неоптимизированный
def test_compiler_optimization_equivalence():
    source_code = """
    int sum = 0;
    for (int i = 0; i < 1000; i++) {
        sum += i;
    }
    return sum;
    """

    # Скомпилировать без оптимизации
    binary_o0 = compile(source_code, optimization_level=0)
    result_o0 = execute(binary_o0)

    # Скомпилировать с агрессивной оптимизацией
    binary_o3 = compile(source_code, optimization_level=3)
    result_o3 = execute(binary_o3)

    assert result_o0 == result_o3, \
        "MR нарушена: Оптимизация изменила семантику программы"

Аддитивные Отношения

Выход для комбинированных входов предсказуемо относится к индивидуальным выходам.

Пример: Калькулятор Налогов

# MR: Налог на отдельные товары должен равняться налогу на комбинированный итог
def test_tax_calculation_additivity():
    item1_price = 100.0
    item2_price = 50.0

    tax_separate = calculate_tax(item1_price) + calculate_tax(item2_price)
    tax_combined = calculate_tax(item1_price + item2_price)

    assert abs(tax_separate - tax_combined) < 0.01, \
        "MR нарушена: Расчет налога не аддитивен"

Метаморфическое Тестирование для Machine Learning

ML модели — это постерный пример проблемы оракула. Как вы проверяете, что классификатор изображений корректно идентифицирует кошку, когда “кошковость” субъективна?

Метаморфические Отношения Классификации Изображений

Инвариантность Яркости

def test_classification_brightness_invariance():
    original_image = load_image("cat.jpg")
    prediction_original = model.predict(original_image)

    # Небольшая корректировка яркости не должна изменить классификацию
    brightened_image = adjust_brightness(original_image, factor=1.1)
    prediction_brightened = model.predict(brightened_image)

    assert prediction_original == prediction_brightened, \
        "MR нарушена: Незначительное изменение яркости изменило классификацию"

Инвариантность Ротации (для подходящих доменов)

def test_classification_rotation_invariance():
    image = load_image("stop_sign.jpg")
    prediction_0 = model.predict(image)

    # Знак стоп должен быть распознан независимо от небольшой ротации
    rotated_image = rotate(image, angle=5)
    prediction_rotated = model.predict(rotated_image)

    assert prediction_0 == prediction_rotated, \
        "MR нарушена: Небольшая ротация изменила классификацию"

Монотонность Уверенности

def test_confidence_monotonicity():
    noisy_image = add_noise(original_image, noise_level=0.3)
    confidence_noisy = model.predict_proba(noisy_image)

    clean_image = original_image
    confidence_clean = model.predict_proba(clean_image)

    assert confidence_clean >= confidence_noisy, \
        "MR нарушена: Зашумленное изображение имеет более высокую уверенность, чем чистое"

MRs Обработки Естественного Языка

Анализ Настроений

def test_sentiment_negation():
    positive_text = "Этот продукт отличный"
    sentiment_positive = analyze_sentiment(positive_text)

    negative_text = "Этот продукт не отличный"
    sentiment_negative = analyze_sentiment(negative_text)

    assert sentiment_positive > 0 and sentiment_negative < 0, \
        "MR нарушена: Отрицание не перевернуло настроение"

Консистентность Перевода

def test_translation_round_trip():
    original_text = "Кошка сидела на коврике"

    # Перевести Русский -> Английский -> Русский
    english = translate(original_text, source="ru", target="en")
    back_to_russian = translate(english, source="en", target="ru")

    similarity = semantic_similarity(original_text, back_to_russian)
    assert similarity > 0.8, \
        "MR нарушена: Круговой перевод потерял значительное значение"

Применения Научных Вычислений

Научные симуляции и численные методы — первоочередные кандидаты для метаморфического тестирования.

MR Физической Симуляции

def test_physics_conservation_of_energy():
    """Тест что симуляция частиц сохраняет энергию"""
    initial_state = {
        'positions': [(0, 0), (1, 0)],
        'velocities': [(1, 0), (-1, 0)],
        'masses': [1, 1]
    }

    # Симулировать для времени T
    final_state = simulate_particles(initial_state, time=10.0)

    # MR: Общая энергия должна быть сохранена (в пределах численной ошибки)
    initial_energy = calculate_total_energy(initial_state)
    final_energy = calculate_total_energy(final_state)

    assert abs(initial_energy - final_energy) < 0.001, \
        "MR нарушена: Энергия не сохранена в симуляции"

MR Численного Интегрирования

def test_integration_subdivision():
    """Тест что подразделение интервала интегрирования дает тот же результат"""
    def function(x):
        return x**2

    # Интегрировать над [0, 10]
    result_full = numerical_integrate(function, a=0, b=10)

    # Интегрировать над [0, 5] и [5, 10] отдельно
    result_part1 = numerical_integrate(function, a=0, b=5)
    result_part2 = numerical_integrate(function, a=5, b=10)
    result_subdivided = result_part1 + result_part2

    assert abs(result_full - result_subdivided) < 0.0001, \
        "MR нарушена: Подразделение изменило результат интегрирования"

Валидация Компиляторов и Интерпретаторов

Метаморфическое тестирование бесценно для валидации компиляторов и преобразований программ.

Эквивалентность Преобразования Кода

def test_dead_code_elimination():
    """Проверить что удаление мертвого кода сохраняет семантику"""
    original_code = """
    x = 10
    y = 20  # Мертвый: никогда не используется
    return x
    """

    optimized_code = optimize(original_code, passes=["dead_code_elimination"])

    # MR: Обе версии должны производить одинаковый выход для всех входов
    test_inputs = [(), (1,), (1, 2)]
    for inputs in test_inputs:
        result_original = execute(original_code, inputs)
        result_optimized = execute(optimized_code, inputs)

        assert result_original == result_optimized, \
            f"MR нарушена: Оптимизация изменила выход для {inputs}"

Кросс-Компиляторная Валидация

def test_cross_compiler_consistency():
    """Разные компиляторы должны производить эквивалентные бинарники"""
    source_code = read_file("program.c")

    binary_gcc = compile_with_gcc(source_code)
    binary_clang = compile_with_clang(source_code)

    # MR: Бинарники должны производить идентичные результаты
    test_cases = generate_test_cases(100)
    for test_input in test_cases:
        result_gcc = execute(binary_gcc, test_input)
        result_clang = execute(binary_clang, test_input)

        assert result_gcc == result_clang, \
            f"MR нарушена: Компиляторы расходятся на входе {test_input}"

Кейс-Стади: Планирование Маршрута Автономного Транспорта

Компания автономных транспортных средств использовала метаморфическое тестирование для валидации своего алгоритма планирования маршрута без знания “правильного” маршрута.

Протестированные Метаморфические Отношения:

  1. MR Добавления Препятствия: Добавление препятствия никогда не должно уменьшать длину маршрута
  2. MR Симметрии: Зеркальные сценарии должны производить зеркальные маршруты
  3. Инкрементальная MR: Планирование A→B→C должно совпадать с планированием A→C, когда B на оптимальном маршруте
  4. MR Безопасности: Любой валидный маршрут должен поддерживать минимальную дистанцию от препятствий

Результаты:

  • Обнаружено 23 бага, которые традиционное тестирование пропустило
  • Найден граничный случай, где алгоритм нарушал запас безопасности во время крутых поворотов
  • Оракул не нужен—нарушения MRs указали дефекты

Пример Имплементации:

def test_obstacle_addition_monotonicity():
    """Добавление препятствия не должно укорачивать маршрут"""
    start = (0, 0)
    goal = (100, 100)
    obstacles_few = [Circle(50, 50, 5)]

    path_few_obstacles = plan_path(start, goal, obstacles_few)

    obstacles_many = obstacles_few + [Circle(60, 60, 5)]
    path_many_obstacles = plan_path(start, goal, obstacles_many)

    assert len(path_many_obstacles) >= len(path_few_obstacles), \
        "MR нарушена: Добавление препятствия укоротило маршрут"

Фреймворк Метаморфического Тестирования

Переиспользуемый фреймворк для метаморфического тестирования:

from abc import ABC, abstractmethod
from typing import Callable, Any, List

class MetamorphicRelation(ABC):
    """Базовый класс для метаморфических отношений"""

    @abstractmethod
    def generate_follow_up_input(self, original_input: Any) -> Any:
        """Сгенерировать последующий вход из оригинала"""
        pass

    @abstractmethod
    def check_relation(self, original_output: Any, followup_output: Any) -> bool:
        """Проверить что метаморфическое отношение сохраняется"""
        pass

class PermutationInvariance(MetamorphicRelation):
    """MR: Выход инвариантен к перестановке входа"""

    def generate_follow_up_input(self, original_input: List) -> List:
        import random
        shuffled = original_input.copy()
        random.shuffle(shuffled)
        return shuffled

    def check_relation(self, original_output: Any, followup_output: Any) -> bool:
        return set(original_output) == set(followup_output)

class MetamorphicTester:
    """Фреймворк для запуска метаморфических тестов"""

    def __init__(self, program: Callable):
        self.program = program
        self.relations: List[MetamorphicRelation] = []

    def add_relation(self, relation: MetamorphicRelation):
        self.relations.append(relation)

    def test(self, input_generator: Callable, num_tests: int = 100):
        violations = []

        for i in range(num_tests):
            original_input = input_generator()
            original_output = self.program(original_input)

            for relation in self.relations:
                followup_input = relation.generate_follow_up_input(original_input)
                followup_output = self.program(followup_input)

                if not relation.check_relation(original_output, followup_output):
                    violations.append({
                        'test_id': i,
                        'relation': type(relation).__name__,
                        'original_input': original_input,
                        'followup_input': followup_input,
                        'original_output': original_output,
                        'followup_output': followup_output
                    })

        return violations

Лучшие Практики для Метаморфического Тестирования

  1. Начинайте с Знания Домена: MRs часто возникают из математических свойств, физических законов или ограничений бизнес-логики

  2. Комбинируйте Множественные MRs: Единственная MR может пропустить баги, которые комбинация раскрывает

  3. Приоритизируйте Высокоценные MRs: Фокусируйтесь на отношениях, которые кодируют критические системные свойства

  4. Автоматизируйте Проверку MR: Интегрируйте метаморфические тесты в CI/CD пайплайны

  5. Четко Документируйте MRs: Каждая MR должна заявлять свое предположение и что она валидирует

  6. Используйте MRs для Дополнения, Не Замены: Комбинируйте метаморфическое тестирование с традиционными подходами к тестированию

Заключение: Тестирование За Пределами Оракула

Метаморфическое тестирование представляет парадигмальный сдвиг в валидации ПО. Фокусируясь на отношениях, а не абсолютной корректности, оно разблокирует тестовые возможности для доменов, ранее считавшихся нетестируемыми:

  • Модели машинного обучения
  • Научные симуляции
  • Компиляторы и оптимизаторы
  • Недетерминированные системы
  • Сложные преобразования данных

Техника не устраняет необходимость в традиционном тестировании—она дополняет его. Когда у вас есть надежный оракул, используйте его. Когда нет, метаморфические отношения предоставляют мощную альтернативу, которая может раскрыть дефекты, которые пропустит конвенциональное тестирование.

В следующий раз, когда вы столкнетесь с нетестируемой системой, спрашивайте не “Каков правильный выход?”, а скорее “Какие отношения должны сохраняться между выходами?” Этот сдвиг в перспективе открывает совершенно новые тестовые возможности.