Код, который работает: Unit и интеграционное тестирование для повышения надежности ПО
Всем привет, меня зовут Андрей Федотов, я бэкенд-разработчик в компании «Цифровая Индустриальная Платформа», где мы создаем одноименный продукт — платформу промышленного интернета вещей ZIIoT Oil&Gas. Наша команда разрабатывает набор сервисов, предназначенных для получения данных от различных источников. И моя статья — это своего рода история нашего проекта через призму юнит и интеграционного тестирования.
Как сказал Кент Бек:»Многие силы мешают нам получить чистый код, а иногда не удается даже получить код, который просто работает». В его книге по TDD (сразу оговорюсь, что TDD мы не используем, но книга очень хорошая) используется подход, когда сначала пишется код «который работает», после чего создается «чистый код». И этот подход противоречит модели разработки на основе архитектуры, в которой сначала мы пишем «чистый код», а потом мучаемся, пытаясь интегрировать в проект код, «который работает». Наша действительность была еще хуже: у нас был код, который работает плохо, и путем больших усилий он стал кодом, который работает. Возможно, однажды мы и к чистому придем.
Чтобы понимать, о чем я говорю, предлагаю посмотреть на юнит-тесты, которые были на проекте еще полтора года назад, примерно тогда же, когда во владение нашей команды перешел набор вышеупомянутых сервисов.
На скриншоте ниже показана папка с названиями файлов с тестами. В данной папке их было около двухсот.
Да, они так и назывались: UnitTest001.cs
, UnitTest020.cs
, UnitTest120.cs
…
На следующей картинке показан пример одного из тестов, находящегося в файле UnitTest015.cs
.
Название теста TestMethod02
— типичное название теста в таких файлах.
В этом тесте дважды делаются запросы к внешним источникам: сначала на запись, потом на чтение. Что уже намекает на то, что это не похоже на юнит-тест. А результаты запросов проверяются беспорядочно.
Вот еще пример теста. TestMethod16
. Содержит одну строчку и вызывает грозный метод Run_142_04
.
Что б это могло значить? Провалимся и увидим, что это за метод.
Это метод расширения, и его код не вмещается примерно в никуда, поэтому тут только его части. И таких методов сотни. Предлагаю даже не пытаться вдаваться в задумку авторов.
Можно подумать, что это какие-то автоматически сгенерированные тесты. Но нет, они были написаны вручную.
Как промежуточный итог, перечислю проблемы, которые были на проекте на тот момент:
много заявок от ТП с багами, требующими вовлечения команды разработки;
сложно поддерживаемый код (не только в тестах, но и вообще);
цель существующих тестов не ясна (нет защиты от багов);
цена исправлений высокая.
Как сказал Владимир Хориков в своей книге «Принципы Unit-тестирования»:»Лучше вообще не писать тест, чем написать плохой тест». Поэтому мы решили написать свои тесты, а от существующих избавиться.
Кстати, если вы не читали эту книгу, то очень советую почитать ее. Это кладезь опыта и рекомендаций. Автор настоящий профессионал и книга отличная, хотя, конечно, там могут быть моменты, которые вашей команде не подойдут (так, например, у нас не прижились рекомендации по именованию тестов, которые были описаны в книге, но это мелочи).
Прежде чем идти дальше, затронем немного теории, а потом посмотрим, какие тесты у нас получились и какие плоды это дало. Тут я опираюсь на определения из книги Владимира Хорикова.
Цель юнит и интеграционного тестирования
Юнит и интеграционное тестирование не сводится к простому написанию тестов. Их цель — обеспечение стабильного роста программного проекта. И ключевым словом здесь является слово «стабильный». В начале жизни проекта развивать его довольно просто. Намного сложнее поддерживать это развитие с течением времени. На графике ниже представлена зависимость времени от прогресса для проектов с тестами и без.
Такое снижение скорости разработки называется программная энтропия.
В нашем проекте мы не ставили написание тестов как самоцель. В первую очередь, мы хотели решить существующие проблемы, о которых я упомянул выше, а также иметь возможность наращивать кодовую базу, не снижая надежности продукта в целом.
Тесты и стресс
Более того, есть связь между тестами и уровнем стресса. Об этом написано в книге по TDD Кента Бека (книга хоть и про TDD, но на деле это увлекательное чтиво с набором интересных историй из практики и жизни автора, поэтому её я также очень рекомендую почитать за чашечкой чая).
Чем больший стресс мы ощущаем, тем меньше мы тестируем разрабатываемый код. Чем меньше мы тестируем разрабатываемый код, тем больше ошибок мы допускаем. Чем больше ошибок мы допускаем, тем выше уровень стресса, который мы ощущаем. Получается замкнутый круг с положительной обратной связью: рост стресса приводит к росту стресса.
Тесты, в свою очередь, превращают стресс в скуку. «Нет, я ничего не сломал. Тесты по-прежнему проходят». Поэтому, наличие тестов также снижает стресс.
Что такое юнит-тест
Он же модульный тест. Общее определение звучит следующим образом: это автоматизированный тест, который:
проверяет правильность работы небольшого фрагмента кода (также называемого юнитом);
делает это быстро;
поддерживает изоляцию от другого кода.
Может возникнуть вопрос, что такое юнит и что такое изоляция?
Надо сказать, что существует две школы юнит-тестирования: классическая и лондонская.
Классической она называется потому, что изначально именно так подходили к юнит-тестированию. Лондонскую школу сформировало сообщество программистов из Лондона (внезапно). Корень различий между классической и лондонской школами — как раз вопрос изоляции. Лондонская школа описывает изоляцию на уровне классов, и юнитом обычно является сам класс. Классическая школа под юнитом подразумевает единицу поведения. В наших сервисах мы придерживаемся классической школы. Обычно она приводит к тестам более высокого качества и лучше подходит для достижения цели — стабильного роста проекта.
Виды зависимостей
Также стоит упомянуть про виды зависимостей, которые есть:
совместные (shared) — доступ имеют более одного теста. Позволяет им влиять на результаты друг друга (например, БД);
приватные (private) — не являющиеся совместными;
внепроцессорные (out-of-process) — работают вне процесса приложения.
Интеграционный тест
Интеграционным называется тест, который не удовлетворяет хотя бы одному критерию из определения юнит-тестов. Он может проверять (и часто проверяет) сразу несколько единиц поведения. Также интеграционный тест проверяет, что код работает в интеграции с совместными зависимостями, внепроцессорными зависимостями или кодом, разработанным другими командами в организации.
Сквозные тесты
Они же API-тесты. Они же end-to-end тесты. Составляют подмножество интеграционных тестов. Тоже проверяют, как код работает с внепроцессорными зависимостями. Отличаются от интеграционных прежде всего тем, что сквозные тесты обычно включают большее число таких зависимостей и обычно проверяют полный путь пользователя.
Что должны делать тесты
В идеале тесты должны проверять не единицы кода, а единицы поведения — нечто, имеющее смысл для предметной области и полезность которого будет понятна бизнесу.
Пирамида тестирования
Концепция классической пирамиды тестирования предписывает определенное соотношение разных типов тестов в проекте. Разные типы тестов в пирамиде выбирают разные компромиссы между быстротой обратной связи и защитой от багов. Тесты более высоких уровней пирамиды отдают предпочтение защите от багов, тогда как тесты нижних уровней выводят на первый план скорость выполнения. И наоборот: чем ниже уровень — тем меньше защита от багов, и чем выше — тем меньше скорость.
А теперь от теории вернемся к тестам в наших сервисах.
В наших сервисах пирамида тестирования на данный момент выглядит вот так:
Наш проект является своего рода прокси и содержит немного бизнес-логики, но много взаимодействий с другими сервисами. Поэтому у нас больше интеграционных тестов. Но не только это является причиной.
WebApplicationFactory
Да, выполнение юнит-тестов всегда быстрее, чем интеграционных. Но не всё так страшно.
Использование WebApplicationFactory
позволяет создавать множество параллельно работающих экземпляров приложения в памяти, изолированных друг от друга, и сделать это «дешево». Благодаря WebApplicationFactory
всё происходит быстро (пока ещё не по цене юнитов, но тем не менее). Подробнее про WebApplicationFactory
можно почитать тут.
И для нас интеграционные тесты это такой своего рода догфудинг. Пока делали, сами поняли слабые и неудобные места своего API и приняли меры.
Для чего мы используем тесты
Мы используем интеграционные и сквозные тесты для проверки поведения пользователя. Эти тесты у нас:
запускаются локально на машинах разработчиков,
взаимодействуют с реальными сервисами на стендах,
есть возможность отладки,
есть возможность запуска сервисов локально.
Юнит-тесты мы используем для проверки логики валидации запросов и проверки любой другой внутренней логики.
Примеры:
Вот так выглядит типичный юнит-тест теперь. Здесь проверяется режим запроса данных.
Вот так выглядит простейший интеграционный тест.
Мы используем нейминг, рекомендуемый Microsoft. Также тут используется паттерн AAA.
Характеристики хороших тестов
Хочу отметить, что наши тесты сейчас это не просто улучшенный код и использование каких-то практик и паттернов, это с нуля написанные тесты, которые соответствуют критериям хороших тестов:
защита от багов,
устойчивость к рефакторингу,
быстрая обратная связь,
простота поддержки.
Первые три являются взаимоисключающими, максимально тест может использовать только два из них. Мы выбрали защиту от багов и устойчивость к рефакторингу как главные. Что касается быстрой обратной связи, то, как упомянул ранее, это не сильно критично и WebApplicationFactory
— это та самая таблетка. Мы также выпустили типизированный клиент для наших пользователей и в тестах сами им пользуемся. Еще один важный критерий — наши тесты являются частью DoD (Definition of Done).
Также есть свойства успешного набора тестов:
интеграция в цикл разработки. Пока что у нас он сейчас условный и на ответственности разработчиков, наш CI сейчас не готов к запуску интеграционных тестов, но это планы на самое ближайшее будущее,
проверка самых важных частей кода,
максимальная защита от багов при минимальных затратах на сопровождение.
Немного статистики
Два года назад у нас было 125 заявок из техподдержки и большинство из них требовали исправлений. Год назад ситуация стала получше: 75 заявок, но всё еще многие из них требовали вовлеченности разработчиков.
На данный момент в текущем году заявок сильно меньше: всего 24. И что самое важное –большинство из них являются заявками на консультации или те, которые до разработчиков не доходили.
По периоду стабилизации во время релизов также наблюдается снижение количества багов.
Конечно, тут много факторов, это заслуга не только наших тестов, ведь мы также переписали большую часть кода сервисов, но тем не менее большое количество недочетов теперь отлавливается на этапе разработки.
Всё вышесказанное указывает на увеличение надежности нашего ПО, о которой и было заявлено в названии статьи.
Краткие выводы и рекомендации
Тесты должны проверять единицы поведения, а не единицы кода,
не пренебрегать практиками написания тестов: нейминг, паттерн AAA и т.д.,
самая показательная метрика — это количество багов,
в современном мире интеграционные тесты не сильно «дороже» юнит-тестов.
Оставляйте свои вопросы, замечания и советы в комментариях — буду рад. Еще я писал про использование HttpClient в нашей работе. Почитать об этом можно тут.