Сбер AIJ 11.12.24
Сбер AIJ 11.12.24
Сбер AIJ 11.12.24

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

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

4К открытий18К показов

В этой части разберемся, что делать, чтобы программа работала как, как задумано. Посмотрим, что желательно подготовить до того, как браться за тестирование кода, настроим тест-контейнеры и 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).
Следите за новыми постами
Следите за новыми постами по любимым темам
4К открытий18К показов