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

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

Что будет покрыто:

  • Каков типичный подход к модульному тестированию (и в чем его проблема)
  • Разное идеи по улучшению нашей стратегии модульного тестирования
  • Мутационное тестирование
  • Тестирование на основе свойств

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

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

Типичный подход к модульному тестированию

Новый проект завершил свою первую итерацию. Разработчики либо написали несколько тестовых случаев в начале разработки, либо в процессе разработки, либо в конце. Конвейер CI настроен, и сообщается о покрытии тестами (% строк кода, затронутых выполнением наших тестов), например. через coverage.py, pytest-cov или nose-cov. Отныне мы будем стремиться к высокому тестовому покрытию, установив пороговое значение (т. е. охват должен составлять ›= 75 %).

Для многих проектов это стандартная практика. Покрытие тестами — это осязаемая и простая в настройке метрика. Более высокое тестовое покрытие обычно связано с меньшим количеством ошибок во время выполнения.

Однако проект может иметь 100-процентное покрытие тестами и по-прежнему создавать ошибки. Как это может быть?

Давайте посмотрим на простой пример. Следующая реализация представляет собой функцию, которая сообщает нам, является ли строка палиндромом или нет:

def is_palindrome(s):
  # Check if the string is the same forwards and backwards
  return s == s[::-1]

Мы можем легко покрыть весь его код одним тестом, который правильно покажет нам, что «redivider» — это палиндром:

def test_is_palindrome():
  assert is_palindrome('redivider') == True

Поздравляем! Теперь мы достигли 100% покрытия тестами.

Однако в какой-то момент в будущем мы поймем, что наша реализация неправильно идентифицирует пустую строку «» как палиндром. Как это может быть? Он не учитывал пробелы! У нас все еще есть 100% покрытие, но нам нужно добавить еще один тест и исправить базовый код.

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

Проблема полагаться исключительно на тестовое покрытие состоит в том, что оно ничего не говорит нам о покрытии во входном пространстве, которое может быть бесконечным!

Идеи для лучшего автоматизированного тестирования

Если типичного подхода в целом недостаточно, то каким еще идеям мы можем следовать? Вот несколько пунктов, которые могут помочь:

  • Как правило, качество наших тестов важнее их количества. Это означает, что мы должны писать тесты, которые четко соответствуют требованиям нашего программного обеспечения.
  • Однако требования недостаточно известны, постоянно меняются или развиваются. Это означает, что мы должны часто адаптировать наши тесты, а также избавляться от старых тестов, которые больше не имеют смысла — затраты на обслуживание и время выполнения наших тестов также являются фактором, который повлияет на наши процесс развития!
  • Более сложные части нашего кода, вероятно, должны иметь более полный набор тестов. Можно измерить сложность модуля или функции, рассчитав, например, их цикломатическую сложность (в питоне см., например, mccabe).
  • Большое количество взаимозависимостей (высокая связанность) в нашем коде увеличивает количество возможных ошибок/ошибок. Один из способов оценки связи в нашей кодовой базе — это афферентная/эфферентная связь (в python см., например, radon cc … -a)
  • Хотя обычно уровень модульного тестирования должен быть самым сильным (см., например, пирамиду тестирования), хорошие сквозные тесты также могут заполнить окончательные пробелы между нашими бизнес-требованиями и нашей реализацией (пробелы, такие как сложные взаимодействия между разрозненными модулями, которые например, сложно моделировать с помощью модульного тестирования) — в python см., например, структуру приемочного тестирования gauge.

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

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

«Мутационное тестирование» — это способ тестирования, при котором базовая реализация изменяется (мутируется) случайным образом. Если наши юнит-тесты все же проходят, т. е. не фиксируют внесенную «ошибку», мы считаем, что мутация «выжила». Поэтому при тестировании мутаций мы пытаемся свести к минимуму выживаемость «мутантов».

Представьте, что у нас есть функция с несколькими ветвями, например:

def f(x):
  if x < 0:
    return -1
  elif x == 0:
    return 0
  elif x < 10:
    return 1
  elif x < 20:
    return 2
  elif x < 100:
    return 10
  else:
    return 11

Несмотря на высокое тестовое покрытие, мы забыли охватить в наших тестах случай, когда x ‹ 100. В этом случае тестирование на мутацию может выявить такой случай, поскольку оно может, например, изменить оператор «x ‹ 100» на «x › 100», и никакая единица тест обнаружит изменение, таким образом произведя выживающую мутацию.

В python мы можем выполнить тестирование мутаций, используя, например. мутатест.

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

  • Он может быть медленным, если его не настроить. Например, может быть запрещено запускать все наборы тестов для каждой мутации.
  • Требуются дополнительные усилия для анализа сохранившихся мутаций и принятия решения о написании новых тестов.
  • Иногда выжившие мутации просто указывают на мертвый код, который нужно удалить (но это тоже неплохо!).

Тестирование на основе свойств

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

Например, давайте представим, что у нас есть кодировщик/декодер строк. Использование, например. что-то вроде гипотезы, мы могли бы написать мета-тест, который утверждает следующий инвариант:

@given(text())
def test_decode_inverts_encode(s):
    assert decode(encode(s)) == s

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

Заключение

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

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