Гайд по чистому коду: учимся писать тесты
Покрываем интеграционным тестом небольшой сервис: объясняем, как сделать это локально и с помощью тест-контейнеров.
4К открытий18К показов
В этой части разберемся, что делать, чтобы программа работала как, как задумано. Посмотрим, что желательно подготовить до того, как браться за тестирование кода, настроим тест-контейнеры и wiremock. И, наконец, напишем интеграционный тест на бизнес-процесс.
Максим Морев
Software Craftsmanship энтузиаст, CTO
Ваганов Вадим
Software Engineer, Head of Profession backend-разработки
Сперва опишем в README раздел «Назначение сервиса»
Чтобы все выглядело понятно, сперва «покопаемся» в теме: проанализируем документацию, если таковая есть, поговорим с экспертами. Они могут простыми словами объяснить, что должен делать сервис.
Далее кратко, в функциональном стиле, распишем, какую задачу он решает и что происходит по процессу от поступления запроса в сервис до его завершения. Будет круто, если мы визуализируем процесс. Времени потратим немного, а коллеги скажут спасибо.
Такой подход учит начинающих разработчиков делать все правильно с самого начала и последовательно оставлять артефакты, которые помогут «братьям по оружию» в будущем — новички учатся на наших с вами репозиториях.
Получим первый бранч и MP. Для удобства важное вынесем в текст:
Назначение сервиса
Сервис является синхронным перекладчиком запросов систем-потребителей в системы-поставщики. Запросы поступают из систем-потребителей по очередям и обрабатываются сервисом-поставщиком согласно обобщенному бизнес-процессу:
- запросы из очереди направляются синхронно по REST в соответствующую систему;
- полученный ответ упаковывается в ответный конверт и отправляется в очередь ответа.
Подробнее в описании каждого процесса.
Обработка запроса из Платежной Системы (PipelineServicePS.kt)
- получает конверт с запросом;
- валидирует входящий запрос;
- отправляет квитанцию о результате валидации и принятии запроса;
- отправляет запрос на получение истории операций в Систему А (REST);
- отправляет полученный ответ в очередь;
- отправляет квитанцию об успехе либо ошибки обработки запроса.
Теперь перейдем к тестам
Напишем интеграционный тест на проверку работоспособности системы. Он дорогой, но покроет максимум кода, позволит убедиться, что система работает, и придаст уверенности при рефакторинге.
В процессе будем использовать инструменты:
- Testcontainers — поможет поднять IBM MQ в контейнерах на время исполнения тестов;
- Wiremock — прекрасный инструмент, чтобы поднять заглушку REST-ресурса на время исполнения тестов.
Полезные ссылки по теме
Конфигурация тестового профиля
Зачищаем конфигурацию перед тестированием: /src/test/resources/application-test.yml
Если вы посмотрите в бранче с README содержание файла конфигурации application-test.yml, то увидите много лишнего: конфиг содержит 49 строк, при этом в основном дублирует настройки основного профиля. Это в какой-то момент приведёт к такой рассинхронизации с основным профилем, что тесты станут давать ложноположительные или ложноотрицательные срабатывания.
Чтобы этого избежать, лучше вынести общие конфигурации в main/resources/application.yml. А в тестовом профиле оставить записи, которые переопределяют основные. Как правило, application-test.yml содержит переменные, зависимые от окружения.
В результате получим 14 строк конфигурации против 49. Конфиг будет содержать только то, что нужно для локального тестирования.
Теперь есть смысл посмотреть на содержание:
Я описал разные способы игр с докером IBM MQ в исходниках. Например, чтобы поднять докер с очередями, нужно зайти в папку docker cd src/test/resources/docker.
Для локального тестирования посредствам монтирования раздела поднимем MQ
docker run \
–env LICENSE=accept \
–env MQ_QMGR_NAME=QM1 \
–publish 1414:1414 \
–publish 9443:9443 \
–publish 9157:9157 \
–mount type=bind,source=./src/test/resources/docker/20-config.mqsc,target=/etc/mqm/20-config.mqsc \
И получим брокер с очередями, которые забираются из файла 20-config.mqsc.
Далее можно написать простой тест конфигурации подключения к брокеру. Отправим в тестовую очередь MY.TEST сообщение и прочитаем его из очереди. Это позволит убедиться в работоспособности тестовой инфраструктуры.
Можно убрать наследование тестового класса от :AbstractIbmMqIntegrationTest() на 16 строке. Затем — запустить из папки /src/test/resources/docker/ команду в терминале, которую мы описали выше, и запустить тест. Так, в логах появятся отклики на тест MQ.
При работе с легаси или кодом без тестов и без документации такой метод может пригодится, чтобы понять, что всё точно сделано правильно.
Рекомендуем тестировать новые инструменты, библиотеки — это поможет сократить расходы и на раннем этапе убедиться, что разработка пошла в правильном направлении. Чаще всего разработчики этого не делают — а зря.
Настраиваем тест-контейнеры
Нам понадобится родная документация фрэймворка:
- https://java.testcontainers.org/quickstart/junit_5_quickstart/
- https://testcontainers.com/guides/testcontainers-container-lifecycle/
- https://java.testcontainers.org/examples/
Добавляем импорт либ для тестирования на тест-контейнерах в build.gradle, строка 35.
Исследуем документацию про Управление жизненным циклом контейнеров тест-контейнеров с использованием JUnit 5. Пробуем запустить паттерн Singleton Containers для запуска тестов на одном контейнере.
Создадим класс AbstractIbmMqIntegrationTest
В нем опишем сам контейнер, строка 16.
Обратите внимание на строку 21 — так можно подмонтировать файл и сконфигурировать стандартный контейнер. Эта полезная фича описана в доке https://java.testcontainers.org/features/files/
Установим переменную окружения для подключения к MQ
Метод setIbmMqProperties, строка 29, заполняем ibm.mq.connName.
Запустим контейнер при создании класса
Функция на строке 37 запускает контейнер перед запуском тестов.
Напишем тест на инфрастурктуру
Убедимся, что этому инструменту можно доверять, затратив минимум внимания и энергии. Ключевыми показателями успеха будут:
- простота документации;
- простота реализации примера по документации;
- работоспособность экспериментального кода по документации.
Получим класс, который уже видели:
MqSimpleTest
Запустим тест и получим зачаток «грини-котлин приложения» (приложения на котлин с зелеными тестами). Тест работает, доки качественные, и мы не плясали с бубном.
Проверено: Фрэймворк testcontainers помогает писать интеграционные тесты.
Настраиваем WireMock
Чтобы локально в тестовом окружении поднять и сконфигурировать заглушку для REST-ресурса можно взять два классных инструмента:
- WireMock: https://wiremock.org/
- Mock Server: https://www.mock-server.com/
В документации Wiremock найдем рекомендацию того, как с ним работать в Spring:
- https://wiremock.org/docs/solutions/spring-boot/
- https://github.com/maciejwalkowiak/wiremock-spring-boot
Добавим зависимость в build.gradle
Добавим конфигурацию ответа сервиса rest-api-gateway. Проверим, что мок-сервер поднимается и подгружает конфигурации ответов из файлов.
Напишем тест на инструмент в классе WireMockWithStubsTest
Строкой 15 описываем инстанс мок-сервера с именем rest-api-gateway. Он определяет url конфигурации рест ресурса rest.configs.api-gateway.url:
Этот url загружается в контекст приложения из /src/main/resources/application.yml, строка 81.
Он используется в классе RestConfiguration, а далее — страшным классом RESTClient, который создатель наделил монструзоным методом на 80 строк sendRequest.
Таким образом, мы понимаем, что приложение на шаге «Отправить запрос история операций по кошельку банка (в систему А по Ресту)» будет обращаться к рест-ресурсу по конфигурации rest.configs.api-gateway.
Настроим и протестируем заглушку
Строка 21 конфигурирует WebTestClient на url рест-ресурса, который мокает wiremock:
Строка 26 тестирует конфигурацю мок-сервера, которую он подтягивает из src/test/resources/wiremock/rest-api-gateway/mappings/wallet-balance.json
Конфигурация содержит описание запроса:
И ответа:
Подробнее про файлы конфигурации запросов, ответов, матчинга ответов в зависимости от параметров в теле запроса, заголовках написано в документации Wiremock.
Тест «подмигнул» зеленым. То есть оба теста на инфру успешны, и мы можем:
- отправить сообщение в очередь;
- прочитать сообщение из очереди;
- отправить http-запрос на REST-ресурс и получать ответ на него.
Напишем интеграционный тест, который отработает недоступность REST-ресурса
PipelineServicePsFailTest
В случае сбоя на шаге отправки REST-запроса квитанция (строка 69) должна содержать сообщение об ошибке. Код, который описывает пайплайн бизнес-процесса, работает некорректно — возвращает Success.
Исправим функцию assertReceiptError так, чтобы она была достоверной и утверждала правильное поведение системы. Тест теперь красный, именно это нам и нужно.
Исходник. Строка 112 — ReceiptStatus. ERROR — ожидаемый статус в квитанции. Тест защищает код от бага красным оком контроля качества интеграционного-теста.
Опишем явно слушатель и уберем лишние классы
Слушатели динамично создаются в MqServiceConfiguration:
Вместо кода на 64 строки, который нужно осознать, простить и принять, мы можем просто описать слушатель на очередь запросов по бизнес-процессу четырьмя строчками кода в классе PipelineServicePS:
Даже если у нас 5–7 процессов лучше описать слушатель явно — так, появляется очевидная точка входа и код проще понять.
Разберем интеграционный тест успешного выполнения бизнес-процесса
Обобщая, поведение системы (SUT — System Under Test) должно быть таким: на вход в очередь запросов мы отправляем конверт с запросом и проверяем, что в ответных очередях есть сообщения.
Очередь для квитанций содержит два сообщения:
- сообщение об успешном принятии запроса;
- сообщение об успехе обработки запроса.
Очередь для ответа содержит результат взаимодействия с REST-ресурсом.
Рассмотрим тест, который проверяет, что система работает правильно. И после — конфигурацию тестовой среды, которая обеспечит его успешное выполнение.
successful wallet balance request
Название понятное и сообщает нам, что тест проверяет успешный запрос баланса кошелька:
Простой понятный тест, который является документацией, написан по паттерну 3A:
- Arrange: подготавливаем сообщение, конверт с запросом createRequestEnvelope (строка 66), чтобы отправить его в очередь запросов IN.QUEUE.PS;
- Act: отправляем сообщение в очередь запросов publishToQueue, воздействуем на SUT (строка 71);
- Assert: проверяем утверждения с фактическими данными, которые выдает SUT, вызвав метод с понятным названием assertResponsesInQueues (строка 73).
Пробежимся по ключевым методам теста: createRequestEnvelope, publishToQueue, assertResponsesInQueues, убедимся, что наш тест является документацией и его легко понять.
createRequestEnvelope
Этот метод просто и понятно описывает процесс создания корректного конверта с запросом.
publishToQueue
Метод прост: используем jmsPublisher: MqPublisher для отправки сообщения в очередь
assertResponsesInQueues
Проверяем, что бизнес-процесс отработал корректно:
- в очереди две квитанции со статусами ReceiptStatus.SUCCESS (строка 77) и ReceiptStatus.ACCEPTED (строка 78);
- в очереди ответа ожидаемое сообщение ответа сервиса (строка 79).
assertReceiptSuccess
Тестируем факт чтения квитанции об успехе операции:
assertReceiptAccepted
Тестируем факт чтения квитанции о том, что запрос получен и взят в работу:
assertResponse
Проверяем ответ в очереди ответа, должно быть все понятно из содержания метода:
assertThatResponse
Добрались до содержания, в котором видно, какой ответ мы ожидаем получить в очереди ответов:
Рекомендуем использовать в тестах Soft Assertions
Обратите внимание: во всех блоках с проверкой утверждений, например, как в assertThatResponse, мы рекомендуем использовать Soft Assertions. Это удобная фича, которая позволяет увидеть все фэйлы теста одним сообщением.
Такая есть в fluent assertions java-либе assertJ, которую мы предпочитаем для работы с утверждениями, и рекомендуем разработчиками. Блок теста на проверку ответа с Soft Assertion выглядит так:
assertThatResponse
Все ошибки краснеют в терминале сразу, показывая, где утверждения в тестах разошлись с фактами:
Это удобнее, чем править утверждение за утверждением и перезапускать тест множество раз.
Конфигурация успешного теста
Контейнер Ibm MQ
Данная конфигурация отличается от конфигурации MqSimpleTest, которую я использовал для тестирования фрэймворка testContainers. Простой инфраструктурный тест опирался на шаблон singleton container, а нам понадобится запустить тесты изолированно друг от друга.
Чтобы протестировать ошибочный и успешный пути исполнения тестов, запустим каждый тест со своим контейнером. Это дорого по ресурсам (cpu, mem), но мы гарантированно получаем изолированный запуск тестов.
Я для простоты выбрал подход использования методов обратного вызова жизненного цикла JUnit 5 (Using JUnit 5 lifecycle callback methods):
На строке 38 создадим контейнер, описанный в классе IbmMqContainer:
В строках 40–46 установим переменную среды ibm.mq.connName для подключения к IBM MQ:
Строки 48–58 отвечают за запуск и остановку контейнеров:
JUnit 5 сначала вызовет метод startContainers(), а затем выполнит все тесты, помеченные аннотацией @Test, Как только все тесты будут выполнены, JUnit 5 вызовет метод обратного вызова @AfterAll (строка 55).
Конфигурация WireMock
В предыдущем разделе мы подробно рассмотрели и протестировали необходимую нам конфигурацию WireMock. В тесте прописываем ее в строке 29:
Этого достаточно, чтобы протестировать успешно бизнес-процесс.
Несколько правил в заключение
- Чтобы защитить себя перед рефакторингом, напишите интеграционный тест на успешное выполнение бизнес-процесса и на один фэйл.
- Остальные крайние точки протестируйте юнит-тестами в отрефаченном коде.
- Пишите тесты на новые инструменты, инфраструктуру — это гарантирует вам продвижение к последующим улучшениям. Проверено — работает.
И пара рекомендаций
- Более подробно про лучшие практики 3A рекомендуем прочитать в статье Владимира Хорикова Making Better Unit Tests: part 1, the AAA pattern.
- Также рекомендуем каждому разработчику купить и прочитать книгу Владимира:«Принципы Юнит-Тестирования», которая вобрала в себя прекрасные эссенции о тестировании.
- Для таких же неугомонных любителей тестирования, как и мы, рекомендуем прекрасный Доклад про DDD от Ion Cooper’а TDD, Where Did It All Go Wrong (Ian Cooper).
4К открытий18К показов