Я фанат тестов. Очень люблю, когда основные части моего кода покрыты полностью, от и до. Первая очевидная причина, для чего это нужно: если я закрываю задачу, то должен более-менее точно знать, что действительно ее выполнил. Тесты помогают получить такую уверенность. Вторая причина: хочется иметь возможность безболезненно вносить изменения и проводить рефакторинг. Наличие тестов позволяет делать это, пусть и с опаской. Если тестов нет, то рефакторинг либо невозможен, либо может стать серьезным испытанием для команды, ведь придется проводить регрессионное тестирование большого количества функционала.
Подход, который я долгое время использовал массово - написание юнит-тестов с использованием Mockito. В заглушки превращается любой сторонний сервис, используемый тестируемым классом. Тесты супер-быстрые, все зеленое, все супер!
Со временем я заметил, что этот подход начал изрядно напрягать. Часто на две строчки кода приходилось писать не менее 10-20 строк теста. Огромное количество переопределений поведения заглушек. Проверки, что тот или иной метод сторонних сервисов более не вызывался или вызывался не более определенного количества раз. Без преувеличения, огромное количество бойлер-плейт кода в тестах.
Дальше - больше. Множество зеленых галочек, появляющееся на экране при запуске тестов, не могло не радовать. Но вот ты решаешь сделать минимальный рефакторинг, оптимизировать какой-то метод. В этот момент все разваливается на куски. Куча красных тестов! Как, почему? Да просто во внутренней реализации ты перешел на использование другого метода, добавил в метод еще один параметр или что-то подобное. В результате куча тестов перестала работать. И теперь, поменяв одну строчку в процессе рефакторинга, надо поменять еще 30 строк в тестах. В какой-то момент начинаешь ловить себя на мысли - “Ни в коем случае, никаких рефакторингов больше!”
Вишенка на торте - ты выкатываешь приложение (нет, не в Прод, а только на тестовый стенд) и бац - ошибки. Приложение просто не стартует. Или стартует, но запрос, так успешно протестированный, ничего не возвращает. А ведь ты уже отчитался, что два дня писал тесты и все у тебя в порядке. В чем же дело? Дело в том, что дьявол кроется в деталях. Где-то перепутал нейминг в настройках, написал некорректный JPQL-запрос, огромное количество реальных деталей, ошибившись в которых, несмотря на пару сотен зеленых и быстрых юнит-тестов, у тебя ничерта не работает и совершенно непонятно, где искать проблему!
Я начал искать выход из ситуации и нашел. В этом мне очень помогла потрясающая книга Владимира Хорикова “Принципы юнит-тестирования” и несколько видео от Victor Rentea, посвященных написанию тестов.
Идея в том, что мы отходим от идеи тестировать наше приложение с изоляцией каждого отдельного класса, и переходим к тестированию нашего приложения по принципу “черного ящика”. Уходим от тестирования отдельных классов к тестированию конкретной функциональности приложения. То есть если наше приложение вызывают через REST с определенными параметрами, в результате чего в базе должна появиться определенная запись, то именно это мы и будем тестировать. Поднимем наше приложение, чтобы оно заработало, окружим его экземплярами вполне реальной инфраструктуры, вызовем его через REST и после проверим, что там в базе появилось.
Да, это несколько дольше запускается, чем классические юнит-тесты. Зато такой подход очень устойчив к рефакторингу, позволяет выявить огромное количество неочевидных проблем, таких, к примеру, как ошибки в конфигурации продюсеров и консюмеров или REST-клиентов, ошибки в запросах SQL и JPQL, ошибки сериализации и так далее. Да просто обновите версию очередной библиотеки и если что-то пошло не так - вы это сразу увидите. Кроме того, так можно проверить действительно сложные головоломные кейсы, которые крайне сложно проверить на стандартных моках из Mockito.
Дальше мы подробно разберем, что нужно сделать, чтобы поднять тестовое окружение, посмотрим несколько приемов для написания тестов.
Хочу отметить, что решения, показанные в тестовом приложении, это не example и не tutorial. Это вполне production-ready реализация. Ключевая цель этого доклада - дать разработчику максимально готовый инструмент, которым он может воспользоваться быстро и максимально быстро преодолеть порог вхождения в тестирование с помощью Testcontainers. Надеюсь, что я с этой задачей справился, но решать вам :) Поехали!
Тестовое приложение
Итак, вот что представляет из себя приложение, которое мы будем тестировать:
Java 21, Spring Boot 3.5.9
СУБД - PostgreSQL
Для миграций БД - Liquibase
Принимает входящие REST-запросы
Отправляет REST-запросы во внешние системы
Принимает сообщения из разных кластеров Кафки
Отправляет сообщения в разные кластера Кафки
Так как подключение к базе у нас одно, то его настройки отдадим Spring Boot (через spring.datasource), а вот подключение к кластерам Кафки реализуем вручную. Делаю на этом акцент, так как в дальнейшем это окажет влияние на реализацию настроек приложения.
Приложение для примера: https://gitflic.ru/project/leva1981/testcontainers-variations
Далее в докладе не будет встречаться вставок кода. Я так поступил не из лени, но из понимания того, что обрывки кода в моем случае просто замусорили бы текст статьи, при этом в отрыве от всего контекста их очень неудобно просматривать.
Что и как будем тестировать
Подход к тестированию следующий:
Все необходимое запускается автоматически при запуске тестов.
Запускается вся необходимая для работы нашего приложения инфраструктура - СУБД, кластера Кафки, имитация внешних web-серверов.
Приложение запускается полностью, подключается к инфраструктуре, накатываются миграции БД.
Для тестирования функционала делаем обращение к нашему приложению через REST или кладем сообщения в топики, которые слушает наше приложение. Затем проверяем результат - это может быть ответ по REST, обновление данных в БД, появление сообщений в исходящих топиках Кафки и так далее.
Такие тесты пишем на все основные сценарии использования. На логику, не связанную с интеграциями или на сложно-тестируемые corner-case, напишем дополнительные init-тесты с использованием Mockito. Наша цель - покрыть интеграционными тестами все основные сценарии, а затем довести покрытие всех классов с логикой до 90-100%.
Давайте прикинем, какое тестовое окружение нам надо поднять, чтобы наше приложение запустилось.
Функция | Что надо сделать |
|---|---|
Работа с СУБД | Поднять PostgreSQL |
Заполнить базу тестовыми данными | |
Настроить наше приложение на подключение к этому экземпляру СУБД | |
Обработка входящих REST-запросов | Запустить приложение на случайном порту, чтобы избежать пересечений с другими приложениями |
Запрос по REST во внешнюю систему | Поднять имитацию веб-сервера внешней системы - Wiremock |
Настроить моки ответов | |
Настроить наше приложение на адрес и порт имитации | |
Отправка сообщения в Кафку | Поднять кластер Кафки |
Настроить наше приложение на подключение к этому экземпляру Кафки для отправки сообщений | |
Получение сообщений из Кафки | Для упрощения будем подключаться к тому же экземпляру Кафки, что и для отправки сообщений |
Настроить наше приложение на подключение к этому экземпляру Кафки для получения сообщений |
Поднимаем тестовое окружение
application-test.yaml - меняем уровень логирования, можем установить другие настройки для тестов
контейнер для Postgres - дополнительно указываем скрипт создания схемы. Тестовые данные прольем позже, после того как Спринг запустит liquibase и будет создана структура БД.
связка WireMockContainer + WireMock. Первое - контейнер, второе - клиент для управления моками в контейнере. Делаем столько контейнеров, сколько у нас интеграций.
initialize() - поднимаем все контейнеры, инициализируем клиентов Wiremock, устанавливаем свойства приложения, создаем топики Кафки и заполняем базу тестовыми данными.
AbstractIntegrationTest - сюда выносим все общие объекты, чтобы избежать повторного поднятия Spring Test Context, а так же инициализируем базовый путь для rest-assured.
Пробуем запуститься - TestTestcontainersVariationsApplication
дорабатываем класс чтобы использовать TestApplicationInitializer
добавляем моки, которые не хотели бы добавлять в тестах, но они нам понадобятся при запуске тестового экземпляра приложения.
Замечание по rest-assured. В Spring Boot 4 Test уже есть встроенное решение, очень похожее на rest-assured. Но так как тестовое приложение написано с использованием SB3.5, то пока используем rest-assured.
Собственно, если мы смогли запустить TestTestcontainersVariationsApplication, значит конфигурирование тестового окружения можно считать успешным. Теперь можно запускать TestTestcontainersVariationsApplication в debug-режиме, тыкать наше приложение палочкой снаружи и смотреть, что из этого выходит. Первый этап выполнен!
Дополнительные приемы
Оставляем контейнеры работать
В TestApplicationInitializer выставляем REUSE_CONTAINERS = true. Это приведет к тому, что между перезапусками тестового приложения (или тестов) контейнеры не будут гаситься. То есть можно сэкономить время на перезапусках.
Обратите внимание на комментарий над константой REUSE_CONTAINERS - понадобится внести небольшое изменение в файл ~/.testcontainers.properties. ~ это каталог текущего пользователя, например, в моем случае это C:\Users\lsukhin.
Из минусов - надо позаботиться о зачистке/обновлении данных в базе, а также самостоятельно погасить запущенные контейнеры, когда в них больше не будет нужды.
Мягкий перезапуск через devtools
Так как к проекту подключена зависимость spring-boot-devtools, при изменении одного или нескольких классов можно выполнить повторную компиляцию и произойдет автоматический мягкий рестарт приложения. См. документацию к devtools и README.md тестового приложения.
Пишем тесты
Вся тестовая инфраструктура готова, теперь мы можем запустить наше приложение и дебажить его в свое удовольствие. Сделаем следующий шаг и перейдем к написанию тестов, которые будут тестировать приложение за нас.
PaymentServiceTests - делаем запрос в наш сервис, он в свою очередь выполняет rest-запрос во внешний сервис, получает ответ и возвращает нам результат
регистрируем ответ от внешнего web-сервиса
загружаем ожидаемый ответ
делаем вызов
выводим полученный ответ в консоль (это нужно, чтобы ответ можно было просмотреть или скопировать, потому что в логах к нему может применяться маскирование или ответ может логироваться не полностью)
сравниваем ответ с ожидаемым ответом
загружаем ожидаемый ответ
делаем вызов
сравниваем ответ с ожидаемым ответом
используя Awaitity.await() проверяем, создалась ли запись в БД и был ли получен ответ от внешнего сервиса через Кафку (что приводит к смене статуса заказа с CREATED на VERIFIED)
sendOrderCreatedEvent_whenExternalServiceKafkaDisabled_dontSendEvents - дополнительный unit-тест на логику с использованием доступа к private-методу, который сделаем
protected+@VisibleForTestingTestcontainersVariationsApplicationTests - визуально проверяем, что поднимается только один контекст Спринг
Готово! Теперь можем запустить тесты через Идею с анализом тестового покрытия и оценить, насколько успешно мы покрыли наше приложение тестами.
Как еще можно
Spring way - поднимаем контейнеры через бины
Существует второй вариант поднять контейнеры в Spring-приложении - через бины. Я не стал его детально разбирать по ряду причин. Во-первых, не Спрингом единым, как говорится. Во-вторых, подход с использованием бинов несколько менее гибкий, особенно когда надо проинициализировать что-то сильно раньше поднятия контекста Спринга.
Для того, чтобы перевести демонстрационное приложение на использование бинов тест-контейнеров, надо раскомментировать/закомментировать несколько строк в файлах TestTestcontainersVariationsApplication и AbstractIntegrationTest.
В этом случае TestApplicationInitializer нам будет не нужен, для конфигурирования будут задействованы TestApplicationConfiguration и TestApplicationConfigurationMocks.
Использование docker-compose
Можно столкнуться с ситуацией, когда совершенно некогда возиться с тест-контейнерами, а приложение надо по-быстрому поднять локально и подебажить. К примеру, это может произойти, когда вам надо быстренько что-то поправить в микросервисе, за который отвечали не вы. В этом случае вы сможете быстренько поднять всю инфраструктуру через docker compose. Я сделал заготовки, которые должны максимально облегчить эту задачу. См. файлы в папке ./docker. Подробное описание расположено в файле ./docker/README.md
Оба файла (docker-compose.yml, docker-admin-ui.yml) используют переменные окружения, определенные в файле .env, для настройки портов, версий образов и других параметров.
docker-compose.yml
Файл docker-compose.yml содержит конфигурацию для запуска нескольких сервисов, необходимых для работы приложения:
kafka-externalservice: Сервис Kafka для обработки сообщений. Настроен с внешними и внутренними портами, а также с переменными окружения для конфигурации брокера и контроллера.
postgres: Сервис PostgreSQL для хранения данных. Использует переменные окружения для настройки пользователя, пароля и имени базы данных. Содержит том для сохранения данных и скрипт инициализации схемы.
wiremock: Сервис WireMock для создания моков HTTP-запросов. Настроен с портом и монтированием директорий для файлов и маппингов.
db-migration: Сервис для применения миграций базы данных с использованием Liquibase. Запускается после успешного запуска PostgreSQL и использует переменные окружения для подключения к базе данных и указания файла миграций.
db-migration-rollback: Сервис для отката миграций базы данных с использованием Liquibase. Аналогично db-migration, но выполняет команду отката до указанной даты.
database-load-test-data: Сервис для загрузки тестовых данных в базу данных PostgreSQL. Использует скрипт _run_scripts.sh для выполнения SQL-запросов.
docker-admin-ui.yml
Файл docker-admin-ui.yml содержит конфигурацию для запуска UI-интерфейсов для управления сервисами:
redis-ui: Интерфейс RedisInsight для управления Redis. Настроен с портом и переменными окружения для подключения к Redis.
kafka-ui: Интерфейс Kafka UI для управления Kafka. Настроен с портом и переменными окружения для подключения к Kafka, включая имя кластера и адрес брокера.
pgadmin: Интерфейс pgAdmin для управления PostgreSQL. Настроен с портом и переменными окружения для подключения к базе данных, включая электронную почту и пароль администратора. Также содержит конфигурационные файлы для серверов и подключения к базе данных.
Получаем разрешение на освоение и внедрение
Итак, вы обогатились теоретическими знаниями. Теперь надо перевести знания в практическую плоскость и внедрить в один из сервисов вашего проекта (для начала). Хотелось бы делать это не в свободное время, а вполне официально.
Вопрос, который вам сразу зададут, будет скорее всего звучать так - “Сколько времени тебе на это нужно?”. Давайте прикинем. На повторный анализ статьи, перенос кода в свой сервис, танцы с бубном при настройке и запуске - на это уйдет рабочий день. Еще один день уйдет на написание первого интеграционного теста. Потом вы переспите со всей этой новой информацией и, скорее всего, доработаете свой первый тест или напишите еще один. То есть может добавиться (или нет) еще один рабочий день. На этом этап освоения можно считать законченным. То есть на вопрос “Сколько времени тебе нужно?” можно смело отвечать “Два, максимум три, рабочих дня.” Срок адекватный, поэтому с большой вероятностью вы сможете завести себе задачу и разобраться с вопросом в рабочее время.
Подведем итоги
Testcontainers решает ту самую боль, с которой я столкнулся, имея дело с сотнями “зеленых” Mockito-тестов: все тесты проходят, но в реальности приложение не работает. Вместо того чтобы тестировать изолированные классы с кучей моков, мы тестируем настоящую функциональность с реальной PostgreSQL, Kafka и другими внешними сервисами. Конечно это медленнее, чем юнит-тесты, но зато когда тесты проходят - приложение действительно работает. Это плата за спокойствие.
Вы перестанете бояться рефакторинга, вы сами (и ваша команда) перестанете спрашивать: “Почему тесты проходят и локально работает, а на стенде падает?” Это не просто инструмент - это смена подхода к тестированию, которая экономит часы отладки интеграционных проблем и дает настоящую уверенность в коде. Это реальная эволюция в подходе к качеству. Это шаг от вопроса “работает ли этот метод?” к вопросу “работает ли мое приложение?”.
Материалы
Приложение:
Использованные материалы:
Школы юнит-тестирования https://habr.com/ru/companies/jugru/articles/571126/
Testcontainers Support in Spring https://docs.spring.io/spring-boot/reference/features/dev-services.html#features.dev-services.testcontainers
Testcontainers in Spring https://docs.spring.io/spring-boot/reference/testing/testcontainers.html
The best way to use Testcontainers with Spring Boot https://maciejwalkowiak.com/blog/testcontainers-spring-boot-setup/
Tremendous Simplification of SpringBoot Development with TestContainers https://gaetanopiazzolla.github.io/java/docker/springboot/2023/05/27/springboot-tc.html
Developer Tools https://docs.spring.io/spring-boot/reference/using/devtools.html
Docker Compose Support in Spring https://docs.spring.io/spring-boot/reference/features/dev-services.html#features.dev-services.docker-compose
TestContainers with Docker Compose in SpringBoot 3 https://gaetanopiazzolla.github.io/java/docker/springboot/2024/05/16/compose-testcont.html
“Принципы юнит-тестирования” Виктор Хориков обзор на книгу https://habr.com/ru/companies/piter/articles/528872/
Victor Rentea на Youtube https://www.youtube.com/channel/UC_RV_tw0mK1aStb6h1eX77g
Автор
Леонид Сухин, Java-разработчик
Telegram: @levaryazan