Изучаем mutmut — инструмент для мутационного тестирования на Python

Автор оригинала: Moshe Zadka
  • Перевод
Мутационное тестирование позволяет выявить баги, которые не покрыты обычными тестами.

У вас есть тесты на все случаи жизни? Или может быть, в репозитории вашего проекта даже лежит справка «О 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 — выбирайте одну из предустановленных ОС, либо устанавливайте из своего образа.

VDSina.ru — хостинг серверов
Серверы в Москве и Амстердаме

Комментарии 8

    0
    Т.е. это уже какое-то метатестирование получается… тестирование тестов.
    • НЛО прилетело и опубликовало эту надпись здесь
        0

        Это просто более полный способ оценить покрытие кода тестами.


        Обычная оценка по затронутым строкам кода по время выполнения зачастую оказывается слишком поверхностной


        Я пробовал мутационное тестирование, когда реализовал сложные алгоритмы, очень полезная тема

          +1
          Это нормально! (tm)

          Для подобных тестов всегда есть проблемы:

          1. Вообще корректен ли тест? Например, мы можем проверять (не заметив это) не то, что функция вернула верное значение, а то, что она просто сработала без исключения.
          2. Все ли ветки исполнения проверены? (стандартное понятие «покрытия кода»).

          Для первого мы делаем инверсные тесты как обязательную часть подхода, но они становятся основным средством в сценарном функциональном тестировании — то есть когда запускается сразу составная среда из нескольких компонентов и проверяется работоспособность всего такого агрегата. А вот на нижних уровнях, где юнит-тесты — основное, такие автоматизированные мутаторы-вариаторы — очень удобное (и теоретически самое полезное, пока не придумали что-то лучше) автоматизированное средство.
            0
            Да я и не оспариваю полезность. Просто пошутил. =)
            Вообще статья очень вовремя подвернулась.
          0
          интересно
            +2
            Автор немного не разобрался, когда он выполнил coverage run `which pytest`, ему в ответе выдали, что 100% тестов было выполнено, а он решил, что это 100% покрытия. Чтобы увидеть покрытие, надо запустить coverage report.
              0

              Меня удивило, что в выводе этой программы (терминальном!) ненастраиваемо печатаются эмодзи. Далеко не каждый терминал такое умеет даже сейчас.
              Новые времена…
              PS: Хабр при отправке комментария их всех почему-то срезает...

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

              Самое читаемое