Тесты, которым можно доверять
March 13, 2021
Я сейчас делаю проект с чистого листа, в котором я основой и ведущий бакэндер. И, естественно, я его делаю в соответствии с Эргономичным Подходом.
Главным условием для того, чтобы кодовая база была эргономичной является набор тестов, которым можно доверять. Если тесты прошли - можно релизать. И никак иначе.
В этом посте я расскажу, как я организовал тестирование "Проекта Л".
Проект Л
Проект под NDA, поэтому в подробностях я его описать не могу, но могу привести ряд ключевых характеристик:
- Целью проекта является проверка бизнес-гипотезы;
- У заказчика есть большое основное приложение, и Проект Л реализуется как внешний клиент этого приложения, работающий через публичное HTTP API;
- Основная ценность проекта заключена во фронте, поэтому на бэке буквально три тривиальных бизнес-правила;
- Большинство реализаций методов Проекта Л идёт за данными в несколько методов основного приложения;
- Поэтому в Проекте Л довольно сложная схема трансформации и кэширования данных;
- У основного проекта есть тестовая среда;
- У Проекта Л фиксированные и довольно ограниченные бюджет и сроки;
- На этапе анализа одного из этапов я допустил две ошибки, и реализацию пришлось два раза сильно перетрясти и отрефакторить.
Релизный цикл внутренних версий
Сейчас у меня разработка идёт примерно такими циклами:
- Что-то подевелопать. Возможно, сильно перетрясти реализацию;
- Запушить код, при пуше СИ прогоняет тесты;
- Если я забыл сам прогнать тесты перед пушем и на СИ тесты упали - исправить ошибки;
- Как только тесты на СИ прошли - выкатывается внутренний релиз.
Ручного тестирования не делаю совсем. И при этом за два месяца разработки мне от заказчика прилетел 1 (один) баг и 0 (ноль) регрессий.
Добился я этого покрыв код шестью видами тестов.
Модули проекта
Проект я разбил на четыре модуля:
- core - "бизнес-логика" (на самом деле интеграционная логика) проекта. Содержит сервисы, модель данных и интерфейсы репозиториев. Плюс я в этот же модуль положил реализацию клиента основного проекта;
- app - инфраструктура проекта. В этом модуле появляется Spring, работа с БД, ХТТП и т.п.
- itests - тесты, работающие по HTTP;
- test-fixtures - константы и утилитарные функции для всех видов тестирования.
Библиотеки тестирования
- JUnit 5 - де-факто стандартная библиотека тестирования для платформы Java;
- kotest - библиотека ассёртов заточенная под Котлин;
- testcontainers - библиотека управления Docker-контейнерами в тестах;
- WireMock - инструмент, который позволяет мокать серверы на уровне HTTP;
- Rest-assured - DSL для написания тестов REST-сервисов.
Виды тестов
В проекте у меня есть следующие виды тестов:
- Юнит-тесты;
- Тесты работы с БД;
- Тесты работы с АПИ основной системы;
- Интеграционные тесты;
- АПИ тесты;
- Сценарные тесты.
В следующем этапе я добавлю ещё пару нагрузочных тестов на критичные сценарии.
Все тесты помечены тегами и можно запустить каждую группу отдельно. Но на практике я завёл два дополнительных Gradle-таска для запуска тестов: allTest - запускает всё кроме сценарных тестов и scenarioTest - запускает только сценарные тесты. Стандартный таск test запускает только тесты без внешних зависимостей (юнит-тесты, тесты БД, интеграционные тесты).
Юнит-тесты
- Назначение
- Проверка бизнес-правил
- Интерфейс
- Прямой вызов методов боевого кода
- Внутренние зависимости*
- Нет
- Внешние зависимости
- Нет
- Количество
- 26
В силу характера проекта по факту их практически нет - три теста на бизнес-правила и два теста парсер ответов основного проекта. А 26 штук их из-за того, что одно из проверяемых бизнес-правил - валидация, и 21 из этих тестов порождены одним параметризованным тестом.
* Внутренними зависимостями я называю зависимости, которые тест запускает сам для себя, а внешними - которые тест ожидает уже запущенными
Тесты работы с БД
- Назначение
- Проверка реализации репозиториев
- Интерфейс
- Прямой вызов методов боевого кода
- Внутренние зависимости
- Postgres (в testcontainers)
- Внешние зависимости
- Нет
- Количество
- 17
Проверяют SQL-выражения на синтаксическую и семантическую корректность и маппинг объекты <-> строки. База для тестов поднимается в контейнере, но одна база используется для всех тестов в запуске.
Тесты работы с АПИ основной системы
- Назначение
- Проверка реализации клиента основной системы
- Интерфейс
- Прямой вызов методов боевого кода
- Внутренние зависимости
- мок основной системы на WireMock
- Внешние зависимости
- основная система
- Количество
- 10
Преимущественно проверяют парсинг ответов. Для проверки обработки ошибок запускается мок-сервер.
Интеграционные тесты
- Назначение
- Проверка поведения крупных блоков ядра системы в случаях, не покрытых АПИ тестами
- Интерфейс
- Прямой вызов методов боевого кода
- Внутренние зависимости
- Postgres (в testcontainers), мок основной системы на WireMock
- Внешние зависимости
- Нет
- Количество
- 6
АПИ тесты
- Назначение
- Эти тесты выполняют сразу четыре роли:
- Проверяют корректность конфигурации спринга, в особенности контроллеров и обработчика ошибок;
- "Ковровым" энд-ту-энд тестированием покрывают весь "хэппи-пас" код системы, а также обработку ожидаемых ошибок;
- Фиксируют АПИ, предотвращая обратно-несовместимые изменения;
- Генерируют сниппеты для Spring Rest Docs.
- Интерфейс
- Обращение к бэку по HTTP через RestAssured и кастомного клиента.
- Внутренние зависимости
- мок основной системы на WireMock
- Внешние зависимости
- запущенное приложение (бэк+Postgres в docker-compose)
- Количество
- 37
Как видно из количества, именно на АПИ тесты я сделал упор в тестировании Проекта Л. Они тестируют всю систему целиком, покрывают все базовые "хэппи пасы" и обработку всех ожидаемых ошибочных ситуаций. Чтобы обеспечить контроль обратной совместимости, АПИ тесты не зависят от модулей основного приложения и, соответственно, урлы и структуры данных в них дублируются.
Запросы делятся на два вида: фикстурные и контрольные. Для выполнения фикстурных запросов написан специальный класс, выставляющий HTTP-интерфейс бэка в виде Котлин-класса. Ответ на фикстурные запросы никак не проверяется. Контрольные запросы выполняются по средствам RestAssured.
Сценарные тесты
- Назначение
- Проверка протоколов взаимодействия фронта и бэка и бэка и основной системы
- Интерфейс
- Обращение к бэку по HTTP через кастомного клиента.
- Внутренние зависимости
- нет
- Внешние зависимости
- запущенное приложение (бэк+Postgres в docker-compose), основная система.
- Количество
- 8
Эти тесты проверяют работу бэка в условиях максимально приближенных к боевым:
- Бэк работает с основной системой;
- Тесты симулируют поведение фронта.
Моки/стабы
Я совсем не использую библиотеки для мокирования классов. Основные причины две.
С одной стороны, я не доверяю тестам с моками. У меня довольно большой опыт работы в проектах с "тестами" на моках, и в таких проектах всегда было ручное тестирование и оно всегда находило регрессии в "зелёных" сборках.
С другой стороны, моки тестируют реализацию, а не контракт. Из-за чего после каждого рефакторинга приходится ещё примерно столько же времени тратить на переписывание тестов.
У Теда Нединского есть пара хороших статей на эту тему:
- The influence of testing on design - тут он пишет чем хороши тесты на границах системы;
Testing, induction, and mocks - а тут он пишет о проблемах, создаваемых моками.
В этой статье мне показалась особенно интересной мысль об однобокости моков - моки говорят, что система будет вести себя таким-то обрзаом. Но при этом никак не контролируют, что в рантайме система будет вести себя таким образом.
Статистика
Бытует мнение, что интеграционные тесты долго писать и долго прогонять, поэтому приведу немного своей статистики.
- Общее количество эндпоинтов
- 10
- Общее количество тестов
- 104
- Время запуска тестов локально
- ~20 секунд
- Время прогона СИ-пайплайна на Github Actions
- 4-5 минут
- Отношение продового кода к тестам
2665 / 3503 = ~3/4
Но надо учитывать, что АПИ-тесты включают довольно развесистые партяники стабов JSON-ответов и доки на Spring Rest Docs:
filter(
document(
"login-ok",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestFields(
fieldWithPath("login").description("Телефон или емейл")
.attributes(credsConstraints.constraintsFor("login")),
fieldWithPath("password").description("Пароль")
.attributes(credsConstraints.constraintsFor("password")),
),
responseFields(
fieldWithPath("token").description("Авторизационный токен")
.attributes(authConstraints.constraintsFor("token")),
)
)
)
Как найти время на тесты
Во-первых, не выделять их в оценке отдельным пунктом:) Не "день на реализацию и день на тесты", а "два дня на реализацию".
Во-вторых, начинать разработку с тестов. Вообще, я не сторонник и не практикую Test-Driven Development, противник Test-Driven Design. Но при этом если я нахожу баг или регрессию, то я первым делом пишу тест, который воспроизводит проблему. Для новых фич бывает по-разному - когда-то я начинаю с АПИ-теста, когда-то с юнит-теста, когда-то совсем без теста.
Тесты, помимо повышения скорости разработки и качества в долгосрочной перспективе, прямо сейчас ускоряют разработку, благодаря автоматизации запуска и проверки функциональности.
Заключения
Не смог найти источник, но кажется в где-то "Чистой Архитектуре" Анкл Боб писал что-то в таком духе:
Если у меня будет выбор взять систему с хорошей архитектурой или хорошими тестами, то я выберу вторую". Если есть тесты которым, можно доверять, то архитектуру можно исправить, а если тестов нет, то с системой ничего невозможно сделать.
И я с этим польностью согласен, надёжные тесты - неотъемлемая часть эргономичной кодовой базы.
В этом проекте мне пришлось дважды перетрясти архитектуру:
- Один раз пришлось поменять отношение между двумя ядерными сущностями с 1-N на N-M;
- Второй раз пришлось поменять загрузку данных с синхронной, на асинхронную предзагрузку.
Благодаря описанной стратегии тестирования мне удалось провести оба рефакторинга без видимых для заказчика регрессий.