Вы написали двенадцать metamorphic relations для вашего pricing engine. Каждый тест проходит. Вы довольны своим покрытием.
Затем клиент сообщает, что bulk discounts считаются задом наперёд. Вы проверяете свой набор соотношений. Ни один тест не упал. У вас были соотношения для аддитивной согласованности, монотонности и идемпотентности. Ни одно из них не поймало ошибку знака в множителе скидки.
Это грязный секрет metamorphic testing: иметь соотношения — не то же самое, что иметь полезные соотношения. Слабое metamorphic relation хуже, чем отсутствие теста вообще, потому что оно убеждает вас, что ваш код корректен, когда это не так.
Что делает соотношение «хорошим»?
Хорошее metamorphic relation обладает высокой способностью обнаружения отказов. Оно ловит реальные баги, которые программисты реально пишут. Остальные — просто накладные расходы.
Классический пример — тестирование функции среднего с off-by-one багом:
def buggy_mean(values):
"""Compute the arithmetic mean."""
return sum(values) / (len(values) - 1) # bug: off-by-one in denominator
Это выглядит правдоподобно, если вы привыкли к формулам выборочной дисперсии. Но это неверно. Вот четыре соотношения, которые люди обычно пишут для функции среднего, и что каждое из них реально ловит:
-
Ограниченность: среднее лежит между min и max. Слабое. Ошибочное среднее всё ещё удовлетворяет этому для большинства входных данных.
-
Идемпотентность на константах:
mean([c] * n) == c. Среднее. Оно ловит баг для списков-констант, но случайные данные редко вызывают падение. -
Инвариантность сдвига:
mean([x + c for x in values]) == mean(values) + c. Сильное. Ошибочный знаменатель ломает это для почти каждого непустого входа. -
Масштабирование:
mean([x * k for x in values]) == mean(values) * k. Сильное. По той же причине. Off-by-one выживает при масштабировании ровно в нуле интересных случаев.
Если ваш test suite проверял только ограниченность и идемпотентность на константах, off-by-one уплыл бы в production. У вас были бы metamorphic tests. Но не было бы обнаружения багов.
Сильные соотношения против слабых
Разница между сильным и слабым соотношением не в том, насколько умно оно звучит. А в том, сколько классов отказов оно устраняет.
Слабое соотношение проверяет свойство, которое большинство некорректных реализаций всё равно случайно удовлетворяют. Ограниченность — идеальный пример. Большинство арифметических багов сохраняют ограниченность, потому что сложение и умножение не изобретают значения вне диапазона входных данных спонтанно. Соотношение, которое проходит для сломанного кода — это театр.
Сильное соотношение кодирует структурное ограничение, которое нарушают сломанные реализации. Инвариантность сдвига сильная, потому что она связывает трансформацию входа с трансформацией выхода через точное равенство. Нет места для манёвра.
Это можно измерить формально. В исследованиях metamorphic testing subsumption соотношения означает, что соотношение A обнаруживает каждый отказ, который обнаруживает соотношение B, плюс некоторые. Если A subsumes B, то B избыточно. Стоит оставить A и удалить B.
На практике вам не нужно формальное доказательство. Вам нужна интуиция: если соотношение всё ещё проходит после того, как вы намеренно ввели правдоподобный баг, оно слабое. Выбросьте его.
Хорошие соотношения покрывают разные домены отказов
Одного сильного соотношения недостаточно. Одно соотношение ловит один класс ошибок. Реальные программы содержат множество независимых типов багов, и ваш набор соотношений должен их покрывать.
Рассмотрим функцию сортировки. Вот соотношения, ранжированные по тому, что они ловят:
Перестановка: вывод содержит те же самые элементы, что и вход. Ловит баги с потерей/дублированием. Пропускает баги порядка.
Порядок: вывод неубывающий. Ловит баги сравнения. Пропускает баги перестановки.
Идемпотентность: sort(sort(x)) == sort(x). Ловит только по-настоящему сломанные реализации, которые разрушают отсортированность. Почти бесполезно.
Стабильность: если сопоставить каждый элемент с его исходным индексом, равные ключи остаются в порядке входа. Ловит операторы сравнения, которые используют >= вместо >.
Подструктура: сортировка префикса, а затем полного списка должна согласовываться в порядке префикса. Ловит баги раннего завершения.
Test suite только с перестановкой и идемпотентностью пропустил бы сортировку, которая всегда возвращает [1, 2, 3]. Набор с перестановкой и порядком поймает этот баг. Добавьте стабильность — и поймаете нестабильные сортировки тоже.
Суть не в том, чтобы собрать как можно больше соотношений. Суть в том, чтобы покрыть независимые режимы отказа. Два соотношения, которые ловят один баг, хуже одного соотношения, которое ловит другой баг.
Компромисс: сильные соотношения сложнее найти
Есть причина, по которой команды пишут слабые соотношения. Сильные соотношения требуют domain knowledge. Нужно достаточно хорошо понимать математическую структуру вашей задачи, чтобы закодировать нетривиальный инвариант.
Для функции среднего инвариантность сдвига очевидна любому со статистическим бэкграундом. Для симуляции частицы эквивалентное соотношение может требовать знания, что гамильтонова динамика сохраняет объём фазового пространства. Не у каждой команды есть такая экспертиза под рукой.
Другая цена — отладка. Когда падает сильное соотношение, нарушение говорит вам, что какое-то структурное свойство сломалось, но баг может быть где угодно в цепочке рассуждений, которая привела к этому свойству. Слабое соотношение вроде «длина вывода равна длине входа» падает ровно одним способом. Сильное соотношение вроде «преобразование Фурье сдвинутого сигнала приобретает линейный фазовый член» падает сотней способов, и выяснение, какой из них — ваш баг, занимает больше времени.
Это центральное напряжение. Слабые соотношения легко писать, легко отлаживать и в основном бесполезны. Сильные соотношения сложно писать, сложно отлаживать и они реально находят баги. Бесплатного обеда не бывает.
Как оценивать metamorphic relation
Прежде чем добавлять соотношение в test suite, проведите его через три проверки:
Тест с намеренным багом. Введите реалистичный баг в вашу реализацию. Падает ли соотношение? Если нет — оно не тянет свою ношу. Попробуйте ошибку знака, off-by-one, перепутанные аргументы, пропущенное граничное условие. Это баги, которые случаются в production. Ваши соотношения должны их ловить.
Тест независимости. Посмотрите на существующие соотношения. Ловит ли какое-либо из них тот же баг? Если да — новое соотношение избыточно. Избыточность — это не безопасность. Это нагрузка на поддержку без предельной выгоды.
Тест фальсифицируемости. Можете ли вы представить правдоподобную сломанную реализацию, которая удовлетворяет соотношению? Если вы можете набросать её за тридцать секунд — соотношение слишком слабое. Хорошее соотношение должно ощущаться как жёсткое ограничение, а не расплывчатая подсказка.
Вот как это выглядит в коде для функции среднего:
import random
def mean(values):
return sum(values) / len(values)
def test_translation_invariance():
values = [random.uniform(-100, 100) for _ in range(20)]
c = 5.5
shifted = [x + c for x in values]
assert mean(shifted) == mean(values) + c
def test_scaling():
values = [random.uniform(-50, 50) for _ in range(20)]
k = 3.0
scaled = [x * k for x in values]
assert mean(scaled) == mean(values) * k
Теперь введите off-by-one баг. Измените len(values) на len(values) - 1. Запустите оба теста. Инвариантность сдвига падает немедленно. Масштабирование падает немедленно. Ограниченность, вероятно, прошла бы.
Вот разница между соотношением, которое заслуживает место в вашем наборе, и тем, которое просто занимает строки.
Начинайте с классов отказов, а не свойств
Ошибка, которую допускают большинство команд — сначала brainstorming свойств. Они спрашивают: «Какие инварианты есть у этой функции?» Это даёт слабые соотношения, потому что инварианты легко формулировать и сложно нарушать.
Вместо этого начинайте с классов отказов. Спросите: «Какие баги написал бы уставший программист в этой функции?» Затем найдите соотношения, которые ловят эти баги.
Для функции геометрического расстояния вероятные баги — ошибки знака, путаница единиц измерения и несоответствие размерностей. Соотношение, проверяющее, что расстояние неотрицательно, ловит ошибки знака. Соотношение, проверяющее масштабирование при координатных преобразованиях, ловит путаницу единиц. Соотношение, проверяющее неравенство треугольника, ловит бессмыслицу с размерностями.
Если вы не можете назвать баг, который ловит соотношение, оно вам не нужно.
Соотношения — дефицитный ресурс. Тратьте их с умом.
Metamorphic testing — это не про метрики покрытия. Это про уверенность. Одно сильное соотношение, которое ловит реальные баги, стоит больше, чем двадцать слабых, которые проходят для сломанного кода.
Проведите аудит ваших существующих metamorphic tests. Введите баг. Посмотрите, что падает. Удалите то, что не падает. Затем добавьте по одному соотношению для каждого класса отказов, который вас реально беспокоит. Это test suite, который оправдывает своё содержание.