Мутационное тестирование: создай мутанта и прокачай тест

Какая же проблема связана с модульными тестами? Дело в том, что можно написать их сколько душе угодно, и они даже пройдут, но это совсем не означает, что код работает согласно вашим ожиданиям. Или говоря словами Эдсгера Вибе Дейкстры: 

Тестирование выявляет наличие, а не отсутствие ошибок. 

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

О недостаточном количестве проведенных тестов вы сможете узнать двумя разными способами.Во-первых, разработанное ПО в итоге не будет соответствовать требованиям, и его пользователи не получат того, за что заплатили. Избежать подобного сценария поможет внимательное ревью кода и его сопоставление с изначальными требованиями. Помимо этого, следует предоставить пользователям ранний доступ к ПО. Во-вторых, не каждая строка и ветвь кода проверяются набором тестов. Это легко определить с помощью целого арсенала существующих инструментов покрытия кода, часто встроенных в исполнитель модульных тестов или используемую библиотеку.

Покрытие кода или тестовое покрытие  —  это показатель степени выполнения кода во время тестирования. Измерить его можно разными способами. В целом, 100% покрытие проекта означает, что модульные тесты проверили каждый участок кода. Достижению высокого процента, безусловно, способствует техника разработки через тестирование. Если вы создадите тест до написания самого кода, то почти без усилий добьетесь 100%.  

Можно предположить, что при высоком проценте покрытия вероятность появления ошибки резко снижается. Но если пойти дальше и убрать все утверждения из модульных тестов, то покрытие останется точно таким же. То есть теперь у вас набор тестов со 100% покрытием, но фактической пользы от него нет.  

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

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

Как же создать качественный набор тестов? И как убедиться в том, что вы не просто выполняете каждую инструкцию, но при этом еще и проверяете ее? Для этих целей и служит мутационное тестирование.

Мутация простого примера кода 

Рассмотрим следующий фрагмент кода Python: 

def double(number: float) -> float:
    return 2.0*number

def test_double() -> None:
    assert double(2.0) == 4.0

Функция double выполняет простую операцию: умножает любое входное значение на 2 и возвращает результат вызывающему компоненту. Протестировать ее не составит труда: нужно лишь проверить, что функция действительно возвращает удвоенное входное значение. Но достаточно ли будет подтвердить, что 4 является результатом умножения 2 на 2? У кода все же 100% покрытие. 

А что если мы заменим умножение (*) на прибавление (+)? В этом случае код будет выглядеть так: 

def double(number: float) -> float:
    return 2.0+number

def test_double() -> None:
    assert double(2.0) == 4.0

Очевидно, что число не удваивается при прибавлении к нему 2. Но несмотря на то, что код явно нерабочий, благодаря указанным числам тест пройдет, поскольку 2+2 =4. Этот небольшой тест не гарантирует семантическую стабильность, и ошибки могут остаться незамеченными. Можно было бы употребить в тесте разные числа, например 3 и 6, делая его устойчивым к изменениям оператора, поскольку 2+3 не равняется 6.

Однако в функции double можно изменить не только оператор. Мы могли бы заменить константу 2.0 другим числом, а также подобрать вместо number другой параметр. Возможен вариант с заменой всего тела функции для возврата константы. С учетом этих “правильных” изменений тест все равно пройдет, хотя код при этом будет нерабочим. Только в этой однострочной функции можно было бы изменить, по крайней мере, 4 компонента, для охвата которых потребуется несколько тест-кейсов. 

Мутационное тестирование на практике 

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

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

Во время сеанса мутационного тестирования инструмент изучает предопределенный набор возможных мутаций, находит для них подходящие участки кода, проводит мутацию и выполняет набор тестов. Если инструмент способен осуществить 10 различных типов мутаций и разместить каждый из них в 10 разных участках кода, то в итоге получаем 100 мутантов и 100 выполняемых тестов. 

Типы мутаций 

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

Список возможных мутаций бесконечен, так что ограничимся обзором лишь некоторых из них: 

  • замена арифметических операторов, например * на +, как в ранее рассмотренном фрагменте кода;
  • замена логических выражений на true или false
  • замена константы другим значением: зачастую к числам добавляется 1 или -1, а к строкам  —  "XX";
  • замена логических операторов, например and на or и наоборот; 
  • замена логических отношений, а именно < на <=
  • замена break на continue
  • замена условных конструкций if и while, например if condition(): на if not condition():
  • блокировка исключений; 
  • удаление вызовов функций и других инструкций; 
  • удаление вызовов super()
  • замена модификаторов доступа, к примеру public на private

Анализ результатов 

Лучший мутант  —  это мертвый мутант. Если после внесения намеренной ошибки набор тестов проваливается, значит, мутант обнаружен и уничтожен. Но если тесты проходят, а мутант остается незамеченным  —  он выжил. Выживший мутант указывает на отсутствие нужного тест-кейса или на необходимость улучшения уже имеющегося.

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

Иногда мутация совсем не меняет поведение кода и остается незамеченной. Речь идет о так называемых идентичных мутантах, наличие которых сильно затрудняет практическое применение мутационного тестирования. Обнаружение таких мутантов представляет собой отдельную научную область, подкрепленную соответствующей проектной документацией.  

Об успешности сеанса мутационного тестирования можно судить по показателю мутации, который отражает соотношение убитых и сгенерированных мутантов. Если все мутанты обезврежены, то он равняется 100%, и вы можете быть уверены в надежности вашего набора тестов. 

Недостатки 

Как видите, данный процесс способен быстро выйти из под контроля. Даже ПО средней сложности может иметь несколько сотен возможных мутаций, каждая из которых повлечет за собой очередное выполнение набора тестов. 

Если для выполнения модульных тестов требуется 30 секунд, то несколько сотен мутаций увеличат это время до нескольких часов, и это без учета времени компиляции в таких языках, как C++, C# или Java. Для решения этой проблемы многие инструменты задействуют различные формы многопроцессорной обработки, в результате чего длительность выполнения определяется уже производительностью системы. Так или иначе мутационное тестирование является дорогостоящей процедурой.

Инструменты 

Многие известные языки программирования предоставляют простые инструменты. Перечислим некоторые из них: 

И многие другие. Эти инструменты обладают разной степенью зрелости и возможностей, но их, определенно, стоит опробовать в деле. Вы только выиграете, повысив надежность и всеохватность своих тестов. 

Заключение 

Мутационное тестирование  —  это форма тестирования методом белого ящика, подразумевающая внесение в код ошибочных изменений (или мутантов) с последующим выполнением набора тестов для каждого из них. Если тест не проходит, то мутант “убит”. Успешное же выполнение теста означает, что он “выжил”. Чем больше убитых мутантов, тем большую семантическую стабильность кода гарантирует набор тестов. 

Данная техника не только высокоэффективная, но и дорогостоящая. Поэтому из-за крупного масштаба задачи ее применение становится непрактичным, даже для небольших проектов ПО. Ведь от модульных тестов ждут быстрой и дешевой обратной связи, буквально за секунды. 

Даже если вы не прибегаете к помощи мутационного тестирования в процессе разработки, сформируйте у себя полезный навык при создании тестов обращать внимание на семантическую стабильность. Вопрос о том, провалится ли тест при изменении определенной части кода, позволит по-новому подойти к написанию кода и его ревью.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Mátyás Kuti: Harness the Power of Evolution to Improve Your Unit Tests