Пирамида тестирования в Python
После опубликации предыдущей статьи получил комментарий: "Я удивлён, что есть тесты, на которые нужно разворачивать докер для имитации сторонних сервисов". И тут я задумался, что возможно стоит немножко рассказать про тесты в целом в экосистеме Python.
- В начале была... пирамида? Нет!
- Пирамида!
- Покрытие тестами
- Тестирование и формальная верификация программ
- Полезные материалы
В начале была... пирамида? Нет!
Тестирование программного обеспечения — процесс исследования, испытания программного продукта, имеющий своей целью проверку соответствия между реальным поведением программы и её ожидаемым поведением на конечном наборе тестов, выбранных определённым образом. (сорс)
На практике под тестированием в программировании понимаются обычно автоматическое или ручное тестирование вашей программы по определенным сценариям приближенным к реальному использованию.
В этом материале рассмотрим именно аспекты касающиеся автоматизированного тестирования.
Пирамида!
Всё почалось с книги "Scrum: гибкая разработка ПО" Майка Кона, где были представлены 3 уровня тестов. Но мы будем разбирать конкретную пирамиду в контексте Python разработки.
В общем про тесты и про пирамиду можно сказать следующее:
- Тест должен быть на том же уровне, что и тестируемый объект: если мы хотим проверить корректность работы минимальной единицы кода (функции или класса), мы не должны для этого разворачивать полноценный стенд с приложением.
- Тесты уровнем выше не проверяют логику тестов уровнем ниже. Например, есть интеграционный тест ленты новостей: в нем мы сначала авторизуемся в системе через логин и пароль и затем с полученным ключом хотим получить список новостей из какой-то заготовленной базы подписок этого пользователя. Нам важно получить конкретный набор новостей для этого пользователя (возможно в определенном порядке, например, по дате создания), но нам не нужно проверять, что именно эти данные лежат в конкретной таблице
newsв PostgreSQL. - Чем выше в пирамиде тесты, тем они:
- сложней в реализации, и соответственно, дороже в реализации;
- важнее для бизнеса и критичней для пользователей;
- замедляют скорость прохождения тестовых наборов
Unit тестирование
(их еще называют модульными тестами, компонентными тестами)
На этом уровне мы тестируем минимальные части кода – функции, методы, классы. Обычно ограничиваются публичными функциями и классами. В Python начинающие разработчики пренебрегают этим и лезут в функции, которые начинаются с нижнего подчеркивания. Если вам приходится этим заниматься, подумайте (!) возможно где-то процесс пошел не туда.
Также обычно в тестах не происходит тестирование "чужого" кода – стандартной библиотеки и модулей Python или сторонних зависимостей.
Unit тесты находят самые базовые ошибки, зачастую очень быстро пишутся и в большом количестве. Они сами по себе быстрые и при изменении кода позволяют быстро убедиться, что вы ничего не сломали.
Даже есть базовая рекомендация, если вы садитесь переписывать старый legacy проект, который не покрыт тестами, то для начала можно покрыть критические участки хотя бы юнитами, чтобы потом спать спокойно.
В 99% разработкой модульных тестов занимается сам разработчик.
Есть довольно известная в среде программистов концепция TDD (Test Driven Development). Ее можно описать фразой: "Сначала – тесты, потом – код". И ее очень удобно применять именно на уровне Unit тестирования, когда вы можете представить себе ожидаемую работу небольшого компонента программы и начать с описания ожидаемого поведения.
У нас есть стандартная библиотека unittest, которая предоставляет базовые классы для написания модульных тестов. Также есть модуль mock в ней, который позволяет делать моки (заглушки) объектов для проверок вызовов отдельных методов, или наоборот позволяет иммитировать работу объектов, которые мы не хотим явно вызывать.
Тесты в unittest представляют собой наследников класса TestCase внутри, которых содержатся отдельные методы, начинающиеся со слова test - собственно сами тесты. Еще есть методы setUp() и tearDown(), которые позволяют подготовить тестовые данные и выполнить какую-то очистку после выполнения тестов.
Своеобразным антиподом этому подходу с классами выступает библиотека pytest, которая преимущественно используется для написания тестов в виде отдельных функций. Соответственно, подготовка и завершение работы после тестов осуществляется тоже в функциях, которые называются фикстуры и помечаются декоратором @pytest.fixture.
Для примера напишем юнит тесты для кусочка игры "Виселица:
- Тестируется поведение одного отдельно взятого класса
- В этих тестах не используются какие-либо сторонние сервисы, службы, не происходят какие-то side effects (например, работа с файловой системой)
Интеграционное тестирование
Проверяет взаимосвязанные компоненты, которые мы проверяли на модульном уровне с другими компонентами, а также интеграцию с компонента с системой.
По идее любой элемент системы, который касается среды, инфраструктуры или пользователя, который пользуется этой системы, требует интеграционных тестов.
В интеграционных тестах практически не используются моки для подмены поведения объектов, потому что моки не содержат ни бизнес-логики, ни реализации (а интеграционные тесты преимущественно завязаны на проверку реализации).
В интеграционном тестировании выделяют 3 подхода:
- Снизу вверх: все мелкие части модуля собираются в один модуль (функцию или метод) и тестируются. Далее собираются следующие мелкие модули в один большой и тестируются с предыдущим и т. д.
- Сверху вниз: сначала проверяем работу крупных модулей, спускаясь ниже добавляем модули уровнем ниже. На этапе проверки верхних уровней могут использоваться моки для сиумляции данных от уровней ниже.
- Большой взрыв: собираем все реализованные модули всех уровней в систему и тестируем. Если что-то не работает или не доработали, то фиксим и дорабатываем
Если ваш проект это не какая-то библиотека абстрактных интерфейсов с чистой бизнес-логикой, то у вас будут интеграционные тесты.
В каждом популярном фреймворке есть тестовый клиент для тестирования запросов к API, а для работы с файловой системой вам придет на помощь тот же pytest.
Из относительно сложных примеров интеграционных тестов, которые часто приходится реализовывать – это тесты внешних API и тесты для баз данных. В них в зависимости от конкретной библиотеки (и даже от конкретного проекта) могут меняться и требования к тестам.
Самый простой пример интеграционных тестов БД – это тест какого-нибудь класса репозитория, который отвечает за работу с одной конкретной таблицей. Чтобы правильно его протестировать необходимо подготавливать базу данных, желательно иметь фабрики для быстрого создания объектов с нужными параметрами в БД, а также очищать таблицу (или даже всю БД) после каждого теста, чтобы не было побочных эффектов.
И вот на интеграционном уровне уже можно почувствовать существенные плюсы от использования pytest для тестирования, а именно систему фикстур. По сути фикстуры выполняют роль dependency injections в тестировании, когда необходимо получать подготовленный объект в нужном месте с минимальными усилиями. Это существенно упрощает код самих тестов и делает их более читабельными. Также это позволяет сэкономить ресурсы, т. к. можно управлять жизненным циклом этих объектов и создавать их не на каждый тест, а на модуль, сессию или пакет.
В качестве примера интеграционных тестов приведу свой репозиторий, где есть только интеграционные тесты по сути.
Для решения проблемы тестирования внешних API есть два подхода:
- Подменять наш клиент, который должен работать с внешним миром на фейковый (например, по флагу
testилиdebug), который соответствует по интерфейсам, но реальных запросов не делает - Подменять сервер, на который отправляются запросы (например, с помощью указания корневого URL сервиса и замены его на тестовый самописный сервер, API которого соответствует реальному, но с фейковыми данными)
В обоих случаях необходимо проводить отдельно ручное тестирование с реальным сервисом, но второй вариант гарантирует, что на момент ручного теста будет проверено больше кода с помощью авто тестов, но и с большими трудозатратами.
Для второго пути могу посоветовать собственную небольшую библиотеку для построения клиентов к внешним API и их тестирования.
End-2-End
На этом уровне проиходит проверка требований к ПО, потому что тесты на этом уровне уже можно привязать к конкретным бизнес-сторям, которые были в требованиях. Зачастую это ручные тесты.
E2E тесты автоматизируются гораздо сложнее, дольше, стоят дороже, сложнее поддерживаются и трудно выполняются при регрессе. Таких тестов должно быть на порядок меньше чем интеграционных.
Обычно их проводят, когда продукт достиг необходимо уровня качества и заказчик ПО ознакомлен с планом приемки.
Здесь нас интересует поведение системы, а не реализация, поэтому по возможности E2E тесты не должны касаться того, как система устроена внутри: что где хранится и как называется. Чтобы это не приводило к ситуациям, когда мы переименуем ключ в кэше, а тест сломается, потому что мы его там не найдем.
E2E тесты могут состоять из простых действий пользователей в системе, которые иммитируют выполнение конкретной User Story с помощь API запросов или если нас интересует UI подсистема, иммитация работы пользователя в браузере с помощью какого-нибудь Selenium. Но нужно помнить, что такие API тесты будут зависеть от конкретных путей роутов и формата передаваемых данных, которые довольно быстро можно поправить или вынести в библиотеку, чтобы сделать их менее хрупкими, то с UI тестами это невозможно и тут уже нужна устоявшаяся кодовая база и дизайн.
Сами тесты удобно писать и запускать с помощью pytest.
Также стоит отметить, что такие тесты, затрагивающие межсервисное взаимодействие, обычно лежат где-то отдельно от кода самих сервисов, требуют отдельного конфига docker compose для подготовки стека.
Как правило, если запускается стек, то все E2E тесты на нем прогоняются без перезапусков отдельных элементов. Поэтому нам нужно беспокоится о том, что, выполняя действия, в каком-то из тестов эффектом волны мы можем сломать другой тест.
Хороших примеров в OpenSource нет и сам пока не написал, так что приложить пока нечего.
Смешение уровней
Часто в проектах грань между интеграционными и модульными тестами довольно тонкая, также очень близко могут находиться интеграционные и E2E тесты.
Не стоит ставить терминологию в главу угла и спорить с коллегами, что и как называть. Лучше всего руководствоваться здравым смыслом и смотреть в суть происходящего в тестах.
Покрытие тестами
Количество тестов само по себе мало говорит о том, насколько хорошо тесты написаны.
В качестве базовой метрики для оценки количества тестов на "достаточность" принято брать процент покрытия тестами.
Буквально эта метрика говорит о том, какая часть исходного кода охвачена тестами.
Этот показатель можно посчитать как:
- Покрытие функций: сколько объявленных функций было вызвано
- Покрытие веток: сколько выполнено веток структур if
- Покрытие условий: какая доля логических подвыражений была протестирована на истину/ложь
- Покрытие строк: сколько строк исходного код протестировано
- Главный инструмент для расчета покрытия в Python – Coverage.py
- Выберите к какому проценту стремиться и по какому критерию
- Хотите поднять покрытие – пишите unit тесты
- Используйте покрытие в pipeline Pull Request, чтобы можно было видеть как меняется процент покрытия и какие участки кода забыли покрыть
- Высокое покрытие тестами не говорит о том, что в приложении не нарушена целостность системы и приложение может запуститься
Тестирование и формальная верификация программ
Как ни странно даже 100% покрытие тестами не гарантирует нам отсутствие в программе ошибок. Оно только гарантирует ожидаемое поведение при указанных в тестах параметрах.
Если мы хотим убедиться, что код корректен в общем случае (условно при любых ожидаемых вводных), используется формальная верификация программ.
Это буквально доказательство корректности программ с помощью математических методов, как, например, в школе на уроках геометрии мы доказывали теоремы.
К такому методу прибегают довольно редко, обычно это требуется для программ, в которых ошибка может стоить очень дорого: космическая отрасль, автономный транспорт, банковские системы и т. д.
Полезные материалы
- [Хабр] Подробнее про пирамиду тестирования
- [Atlassian] Что такое покрытие кода?
- [Python Doc] unittest — Unit testing framework
- [pytest] Documentation
- [Coverage.py] Documentation
- [Real Python] Effective Python Testing With pytest
- [Real Python] Python's unittest: Writing Unit Tests for Your Code
- Кент Бек. Экстремальное программирование: разработка через тестирование
- [Хабр] Что такое формальная верификация?