Гайд по чистому коду: учимся писать тесты

Логотип компании Газпромбанк

Покрываем интеграционным тестом небольшой сервис: объясняем, как сделать это локально и с помощью тест-контейнеров.

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

Сперва опишем в README раздел «Назначение сервиса»

Чтобы все выглядело понятно, сперва «покопаемся» в теме: проанализируем документацию, если таковая есть, поговорим с экспертами. Они могут простыми словами объяснить, что должен делать сервис.

Далее кратко, в функциональном стиле, распишем, какую задачу он решает и что происходит по процессу от поступления запроса в сервис до его завершения. Будет круто, если мы визуализируем процесс. Времени потратим немного, а коллеги скажут спасибо.

Такой подход учит начинающих разработчиков делать все правильно с самого начала и последовательно оставлять артефакты, которые помогут «братьям по оружию» в будущем — новички учатся на наших с вами репозиториях.

Получим первый бранч и MP. Для удобства важное вынесем в текст:

Назначение сервиса

Сервис является синхронным перекладчиком запросов систем-потребителей в системы-поставщики. Запросы поступают из систем-потребителей по очередям и обрабатываются сервисом-поставщиком согласно обобщенному бизнес-процессу:

  • запросы из очереди направляются синхронно по REST в соответствующую систему;
  • полученный ответ упаковывается в ответный конверт и отправляется в очередь ответа.

Подробнее в описании каждого процесса.

Обработка запроса из Платежной Системы (PipelineServicePS.kt)

  • получает конверт с запросом;
  • валидирует входящий запрос;
  • отправляет квитанцию о результате валидации и принятии запроса;
  • отправляет запрос на получение истории операций в Систему А (REST);
  • отправляет полученный ответ в очередь;
  • отправляет квитанцию об успехе либо ошибки обработки запроса.
Гайд по чистому коду: учимся писать тесты 1

Теперь перейдем к тестам

Напишем интеграционный тест на проверку работоспособности системы. Он дорогой, но покроет максимум кода, позволит убедиться, что система работает, и придаст уверенности при рефакторинге.

В процессе будем использовать инструменты:

  • 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. Конфиг будет содержать только то, что нужно для локального тестирования.

Теперь есть смысл посмотреть на содержание:

Гайд по чистому коду: учимся писать тесты 2

Я описал разные способы игр с докером 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.

Гайд по чистому коду: учимся писать тесты 3

Далее можно написать простой тест конфигурации подключения к брокеру. Отправим в тестовую очередь MY.TEST сообщение и прочитаем его из очереди. Это позволит убедиться в работоспособности тестовой инфраструктуры.

Гайд по чистому коду: учимся писать тесты 4

Можно убрать наследование тестового класса от :AbstractIbmMqIntegrationTest() на 16 строке. Затем — запустить из папки /src/test/resources/docker/ команду в терминале, которую мы описали выше, и запустить тест. Так, в логах появятся отклики на тест MQ.

Гайд по чистому коду: учимся писать тесты 5

При работе с легаси или кодом без тестов и без документации такой метод может пригодится, чтобы понять, что всё точно сделано правильно.

Рекомендуем тестировать новые инструменты, библиотеки — это поможет сократить расходы и на раннем этапе убедиться, что разработка пошла в правильном направлении. Чаще всего разработчики этого не делают — а зря.

Настраиваем тест-контейнеры

Нам понадобится родная документация фрэймворка:

Добавляем импорт либ для тестирования на тест-контейнерах в build.gradle, строка 35.

Гайд по чистому коду: учимся писать тесты 6

Исследуем документацию про Управление жизненным циклом контейнеров тест-контейнеров с использованием JUnit 5. Пробуем запустить паттерн Singleton Containers для запуска тестов на одном контейнере.

Создадим класс AbstractIbmMqIntegrationTest

Гайд по чистому коду: учимся писать тесты 7

В нем опишем сам контейнер, строка 16.

Гайд по чистому коду: учимся писать тесты 8

Обратите внимание на строку 21 — так можно подмонтировать файл и сконфигурировать стандартный контейнер. Эта полезная фича описана в доке https://java.testcontainers.org/features/files/

Установим переменную окружения для подключения к MQ

Метод setIbmMqProperties, строка 29, заполняем ibm.mq.connName.

Гайд по чистому коду: учимся писать тесты 9

Запустим контейнер при создании класса

Функция на строке 37 запускает контейнер перед запуском тестов.

Гайд по чистому коду: учимся писать тесты 10

Напишем тест на инфрастурктуру

Убедимся, что этому инструменту можно доверять, затратив минимум внимания и энергии. Ключевыми показателями успеха будут:

  • простота документации;
  • простота реализации примера по документации;
  • работоспособность экспериментального кода по документации.

Получим класс, который уже видели:

MqSimpleTest

Гайд по чистому коду: учимся писать тесты 11

Запустим тест и получим зачаток «грини-котлин приложения» (приложения на котлин с зелеными тестами). Тест работает, доки качественные, и мы не плясали с бубном.

Проверено: Фрэймворк testcontainers помогает писать интеграционные тесты.

Настраиваем WireMock

Чтобы локально в тестовом окружении поднять и сконфигурировать заглушку для REST-ресурса можно взять два классных инструмента:

В документации Wiremock найдем рекомендацию того, как с ним работать в Spring:

Добавим зависимость в build.gradle

Гайд по чистому коду: учимся писать тесты 12

Добавим конфигурацию ответа сервиса rest-api-gateway. Проверим, что мок-сервер поднимается и подгружает конфигурации ответов из файлов.

Напишем тест на инструмент в классе WireMockWithStubsTest

Гайд по чистому коду: учимся писать тесты 13

Строкой 15 описываем инстанс мок-сервера с именем rest-api-gateway. Он определяет url конфигурации рест ресурса rest.configs.api-gateway.url:

Гайд по чистому коду: учимся писать тесты 14

Этот url загружается в контекст приложения из /src/main/resources/application.yml, строка 81.

Гайд по чистому коду: учимся писать тесты 15

Он используется в классе RestConfiguration, а далее — страшным классом RESTClient, который создатель наделил монструзоным методом на 80 строк sendRequest.

Таким образом, мы понимаем, что приложение на шаге «Отправить запрос история операций по кошельку банка (в систему А по Ресту)» будет обращаться к рест-ресурсу по конфигурации rest.configs.api-gateway.

Настроим и протестируем заглушку

Строка 21 конфигурирует WebTestClient на url рест-ресурса, который мокает wiremock:

Гайд по чистому коду: учимся писать тесты 16

Строка 26 тестирует конфигурацю мок-сервера, которую он подтягивает из src/test/resources/wiremock/rest-api-gateway/mappings/wallet-balance.json

Гайд по чистому коду: учимся писать тесты 17
Гайд по чистому коду: учимся писать тесты 18

Конфигурация содержит описание запроса:

Гайд по чистому коду: учимся писать тесты 19

И ответа:

Гайд по чистому коду: учимся писать тесты 20

Подробнее про файлы конфигурации запросов, ответов, матчинга ответов в зависимости от параметров в теле запроса, заголовках написано в документации Wiremock.

Тест «подмигнул» зеленым. То есть оба теста на инфру успешны, и мы можем:

  • отправить сообщение в очередь;
  • прочитать сообщение из очереди;
  • отправить http-запрос на REST-ресурс и получать ответ на него.

Напишем интеграционный тест, который отработает недоступность REST-ресурса

PipelineServicePsFailTest

Гайд по чистому коду: учимся писать тесты 21

В случае сбоя на шаге отправки REST-запроса квитанция (строка 69) должна содержать сообщение об ошибке. Код, который описывает пайплайн бизнес-процесса, работает некорректно — возвращает Success.

Гайд по чистому коду: учимся писать тесты 22

Исправим функцию assertReceiptError так, чтобы она была достоверной и утверждала правильное поведение системы. Тест теперь красный, именно это нам и нужно.

Исходник. Строка 112 — ReceiptStatus. ERROR — ожидаемый статус в квитанции. Тест защищает код от бага красным оком контроля качества интеграционного-теста.

Гайд по чистому коду: учимся писать тесты 23

Опишем явно слушатель и уберем лишние классы

Слушатели динамично создаются в MqServiceConfiguration:

Гайд по чистому коду: учимся писать тесты 24

Вместо кода на 64 строки, который нужно осознать, простить и принять, мы можем просто описать слушатель на очередь запросов по бизнес-процессу четырьмя строчками кода в классе PipelineServicePS:

Гайд по чистому коду: учимся писать тесты 25

Даже если у нас 5–7 процессов лучше описать слушатель явно — так, появляется очевидная точка входа и код проще понять.

Разберем интеграционный тест успешного выполнения бизнес-процесса

Обобщая, поведение системы (SUT — System Under Test) должно быть таким: на вход в очередь запросов мы отправляем конверт с запросом и проверяем, что в ответных очередях есть сообщения.

Очередь для квитанций содержит два сообщения:

  • сообщение об успешном принятии запроса;
  • сообщение об успехе обработки запроса.

Очередь для ответа содержит результат взаимодействия с REST-ресурсом.

Рассмотрим тест, который проверяет, что система работает правильно. И после — конфигурацию тестовой среды, которая обеспечит его успешное выполнение.

successful wallet balance request

Название понятное и сообщает нам, что тест проверяет успешный запрос баланса кошелька:

Гайд по чистому коду: учимся писать тесты 26

Простой понятный тест, который является документацией, написан по паттерну 3A:

  • Arrange: подготавливаем сообщение, конверт с запросом createRequestEnvelope (строка 66), чтобы отправить его в очередь запросов IN.QUEUE.PS;
  • Act: отправляем сообщение в очередь запросов publishToQueue, воздействуем на SUT (строка 71);
  • Assert: проверяем утверждения с фактическими данными, которые выдает SUT, вызвав метод с понятным названием assertResponsesInQueues (строка 73).

Пробежимся по ключевым методам теста: createRequestEnvelope, publishToQueue, assertResponsesInQueues, убедимся, что наш тест является документацией и его легко понять.

createRequestEnvelope

Этот метод просто и понятно описывает процесс создания корректного конверта с запросом.

Гайд по чистому коду: учимся писать тесты 27

publishToQueue

Метод прост: используем jmsPublisher: MqPublisher для отправки сообщения в очередь

Гайд по чистому коду: учимся писать тесты 28

assertResponsesInQueues

Проверяем, что бизнес-процесс отработал корректно:

  • в очереди две квитанции со статусами ReceiptStatus.SUCCESS (строка 77) и ReceiptStatus.ACCEPTED (строка 78);
  • в очереди ответа ожидаемое сообщение ответа сервиса (строка 79).
Гайд по чистому коду: учимся писать тесты 29

assertReceiptSuccess

Тестируем факт чтения квитанции об успехе операции:

Гайд по чистому коду: учимся писать тесты 30

assertReceiptAccepted

Тестируем факт чтения квитанции о том, что запрос получен и взят в работу:

Гайд по чистому коду: учимся писать тесты 31

assertResponse

Проверяем ответ в очереди ответа, должно быть все понятно из содержания метода:

Гайд по чистому коду: учимся писать тесты 32

assertThatResponse

Добрались до содержания, в котором видно, какой ответ мы ожидаем получить в очереди ответов:

Гайд по чистому коду: учимся писать тесты 33

Рекомендуем использовать в тестах Soft Assertions

Обратите внимание: во всех блоках с проверкой утверждений, например, как в assertThatResponse, мы рекомендуем использовать Soft Assertions. Это удобная фича, которая позволяет увидеть все фэйлы теста одним сообщением.

Такая есть в fluent assertions java-либе assertJ, которую мы предпочитаем для работы с утверждениями, и рекомендуем разработчиками. Блок теста на проверку ответа с Soft Assertion выглядит так:

assertThatResponse

Гайд по чистому коду: учимся писать тесты 34

Все ошибки краснеют в терминале сразу, показывая, где утверждения в тестах разошлись с фактами:

Гайд по чистому коду: учимся писать тесты 35

Это удобнее, чем править утверждение за утверждением и перезапускать тест множество раз.

Конфигурация успешного теста

Контейнер Ibm MQ

Данная конфигурация отличается от конфигурации MqSimpleTest, которую я использовал для тестирования фрэймворка testContainers. Простой инфраструктурный тест опирался на шаблон singleton container, а нам понадобится запустить тесты изолированно друг от друга.

Чтобы протестировать ошибочный и успешный пути исполнения тестов, запустим каждый тест со своим контейнером. Это дорого по ресурсам (cpu, mem), но мы гарантированно получаем изолированный запуск тестов.

Я для простоты выбрал подход использования методов обратного вызова жизненного цикла JUnit 5 (Using JUnit 5 lifecycle callback methods):

Гайд по чистому коду: учимся писать тесты 36

На строке 38 создадим контейнер, описанный в классе IbmMqContainer:

Гайд по чистому коду: учимся писать тесты 37

В строках 40–46 установим переменную среды ibm.mq.connName для подключения к IBM MQ:

Гайд по чистому коду: учимся писать тесты 38

Строки 48–58 отвечают за запуск и остановку контейнеров:

Гайд по чистому коду: учимся писать тесты 39

JUnit 5 сначала вызовет метод startContainers(), а затем выполнит все тесты, помеченные аннотацией @Test, Как только все тесты будут выполнены, JUnit 5 вызовет метод обратного вызова @AfterAll (строка 55).

Конфигурация WireMock

В предыдущем разделе мы подробно рассмотрели и протестировали необходимую нам конфигурацию WireMock. В тесте прописываем ее в строке 29:

Гайд по чистому коду: учимся писать тесты 40

Этого достаточно, чтобы протестировать успешно бизнес-процесс.

Несколько правил в заключение

  • Чтобы защитить себя перед рефакторингом, напишите интеграционный тест на успешное выполнение бизнес-процесса и на один фэйл.
  • Остальные крайние точки протестируйте юнит-тестами в отрефаченном коде.
  • Пишите тесты на новые инструменты, инфраструктуру — это гарантирует вам продвижение к последующим улучшениям. Проверено — работает.

И пара рекомендаций

  • Более подробно про лучшие практики 3A рекомендуем прочитать в статье Владимира Хорикова Making Better Unit Tests: part 1, the AAA pattern.
  • Также рекомендуем каждому разработчику купить и прочитать книгу Владимира:«Принципы Юнит-Тестирования», которая вобрала в себя прекрасные эссенции о тестировании.
  • Для таких же неугомонных любителей тестирования, как и мы, рекомендуем прекрасный Доклад про DDD от Ion Cooper’а TDD, Where Did It All Go Wrong (Ian Cooper).
Тестирование
Для начинающих
Рефакторинг
2469