Мутационное тестирование позволяет выявить баги, которые не покрыты обычными тестами.
У вас есть тесты на все случаи жизни? Или может быть, в репозитории вашего проекта даже лежит справка «О 100-процентном тестовом покрытии»? Но разве в реальной жизни всё так просто и достижимо?
С юнит-тестами всё более-менее понятно: они должны быть написаны. Иногда они не работают должным образом: существуют ложные срабатывания или тесты с ошибками, которые выдают то «да», то «нет» без каких-либо изменений кода. Небольшие ошибки, которые вы можете обнаружить в модульных тестах, ценны, но часто их фиксит сам разработчик — до того, как он делает коммит. Но нас по-настоящему беспокоят те ошибки, которые часто ускользают из поля зрения. И что хуже всего, они часто решают заявить о себе как раз тогда, когда продукт попадает в руки пользователя.
Именно мутационное тестирование позволяет бороться с такими коварными багами. Оно изменяет исходный код заранее определённым способом (внося специальные баги — так называемые «мутанты») и проверяет, выживут ли эти мутанты в других тестах. Любой мутант, который выжил в модульном тесте, заставляет сделать вывод, что стандартные тесты не обнаружили соответствующий модифицированный фрагмент кода, который содержит ошибку.
В Python основным инструментом мутационного тестирования является mutmut.
Представьте, что нам нужно написать код, который вычисляет угол между часовой и минутной стрелками в аналоговых часах:
def hours_hand(hour, minutes):
base = (hour % 12 ) * (360 // 12)
correction = int((minutes / 60) * (360 // 12))
return base + correction
def minutes_hand(hour, minutes):
return minutes * (360 // 60)
def between(hour, minutes):
return abs(hours_hand(hour, minutes) - minutes_hand(hour, minutes))
Напишем обычный юнит-тест:
import angle
def test_twelve():
assert angle.between(12, 00) == 0
В коде нет ни одного if. Давайте проверим, насколько покрывает все возможные ситуации такой юнит-тест:
$ coverage run `which pytest`
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-5.4.3, py-1.8.2, pluggy-0.13.1
rootdir: /home/moshez/src/mut-mut-test
collected 1 item
tests/test_angle.py . [100%]
============================== 1 passed in 0.01s ===============================
Отлично! Вроде бы 100-процентное покрытие. Но что произойдёт, когда мы проведём мутационное тестирование?
О нет! Из 21 выжили целых 16 мутантов. Как же так?
Для каждого мутационного теста нужно изменить часть исходного кода, которая имитирует потенциальную ошибку. Примером такой модификации является изменение оператора сравнения «>» на «> =». Если для этого граничного условия нет модульного теста, этот баг-мутант «выживет»: это потенциальная ошибка, которую не обнаружит ни один из обычных тестов.
Ну ладно. Всё ясно. Надо писать юнит-тесты лучше. Тогда с помощью команды results посмотрим, какие конкретно изменения были сделаны:
$ mutmut results
<snip>
Survived :( (16)
---- angle.py (16) ----
4-7, 9-14, 16-21
$ mutmut apply 4
$ git diff
diff --git a/angle.py b/angle.py
index b5dca41..3939353 100644
--- a/angle.py
+++ b/angle.py
@@ -1,6 +1,6 @@
def hours_hand(hour, minutes):
hour = hour % 12
- base = hour * (360 // 12)
+ base = hour / (360 // 12)
correction = int((minutes / 60) * (360 // 12))
return base + correction
Это типичный пример работы mumut: он анализирует исходный код и заменяет одни операторы на другие: например, сложение на вычитание или, как в данном случае, умножение на деление. Модульные тесты, вообще говоря, должны выявлять ошибки при смене оператора; в противном случае они не проверяют поведение программы эффективно. Этой логики mutmut как раз и придерживается, внося те или иные изменения.
Мы можем использовать команду mutmut apply (применить мутацию к нашему коду) для выжившего мутанта. Ничего себе: оказывается, мы не проверили, правильно ли использовался параметр «час» (hour). Исправим это:
$ git diff
diff --git a/tests/test_angle.py b/tests/test_angle.py
index f51d43a..1a2e4df 100644
--- a/tests/test_angle.py
+++ b/tests/test_angle.py
@@ -2,3 +2,6 @@ import angle
def test_twelve():
assert angle.between(12, 00) == 0
+
+def test_three():
+ assert angle.between(3, 00) == 90
Раньше мы проверяли только для 12. Добавление проверки для значения 3 спасёт ситуацию?
Этот новый тест сумел убить двух мутантов: это лучше, чем раньше, но нужно поработать ещё. Я не буду сейчас писать решение для каждого из 14 оставшихся случаев, потому что идея и так понятна (можете ли вы убить всех мутантов самостоятельно?)
В дополнением к инструментам измерения покрытия, мутационное тестирование также позволяет оценить, насколько всеобъемлющими являются ваши тесты. Таким образом вы можете улучшить тесты: любой из выживших мутантов является ошибкой, которую мог совершить разработчик, а также потенциальным дефектом вашего продукта. Так что, желаю вам убить побольше мутантов!
На правах рекламы
VDSina предлагает виртуальные серверы на Linux и Windows — выбирайте одну из предустановленных ОС, либо устанавливайте из своего образа.
Автор: Mikhail