Тесты, которым можно доверять

March 13, 2021

Я сейчас делаю проект с чистого листа, в котором я основой и ведущий бакэндер. И, естественно, я его делаю в соответствии с Эргономичным Подходом.

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

В этом посте я расскажу, как я организовал тестирование "Проекта Л".

Проект Л

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

  • Целью проекта является проверка бизнес-гипотезы;
  • У заказчика есть большое основное приложение, и Проект Л реализуется как внешний клиент этого приложения, работающий через публичное HTTP API;
  • Основная ценность проекта заключена во фронте, поэтому на бэке буквально три тривиальных бизнес-правила;
  • Большинство реализаций методов Проекта Л идёт за данными в несколько методов основного приложения;
  • Поэтому в Проекте Л довольно сложная схема трансформации и кэширования данных;
  • У основного проекта есть тестовая среда;
  • У Проекта Л фиксированные и довольно ограниченные бюджет и сроки;
  • На этапе анализа одного из этапов я допустил две ошибки, и реализацию пришлось два раза сильно перетрясти и отрефакторить.

Релизный цикл внутренних версий

Сейчас у меня разработка идёт примерно такими циклами:

  1. Что-то подевелопать. Возможно, сильно перетрясти реализацию;
  2. Запушить код, при пуше СИ прогоняет тесты;
  3. Если я забыл сам прогнать тесты перед пушем и на СИ тесты упали - исправить ошибки;
  4. Как только тесты на СИ прошли - выкатывается внутренний релиз.

Ручного тестирования не делаю совсем. И при этом за два месяца разработки мне от заказчика прилетел 1 (один) баг и 0 (ноль) регрессий.

Добился я этого покрыв код шестью видами тестов.

Модули проекта

Проект я разбил на четыре модуля:

  1. core - "бизнес-логика" (на самом деле интеграционная логика) проекта. Содержит сервисы, модель данных и интерфейсы репозиториев. Плюс я в этот же модуль положил реализацию клиента основного проекта;
  2. app - инфраструктура проекта. В этом модуле появляется Spring, работа с БД, ХТТП и т.п.
  3. itests - тесты, работающие по HTTP;
  4. 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. Один раз пришлось поменять отношение между двумя ядерными сущностями с 1-N на N-M;
  2. Второй раз пришлось поменять загрузку данных с синхронной, на асинхронную предзагрузку.

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