| Внедряем Testcontainers за два дня или как перестать бояться рефакторинга и начать доверять своим тестам |
| 29.04.2026 00:00 | ||||||||||||||||||||||||
|
Автор: Леонид Сухин Я фанат тестов. Очень люблю, когда основные части моего кода покрыты полностью, от и до. Первая очевидная причина, для чего это нужно: если я закрываю задачу, то должен более-менее точно знать, что действительно ее выполнил. Тесты помогают получить такую уверенность. Вторая причина: хочется иметь возможность безболезненно вносить изменения и проводить рефакторинг. Наличие тестов позволяет делать это, пусть и с опаской. Если тестов нет, то рефакторинг либо невозможен, либо может стать серьезным испытанием для команды, ведь придется проводить регрессионное тестирование большого количества функционала. Подход, который я долгое время использовал массово - написание юнит-тестов с использованием Mockito. В заглушки превращается любой сторонний сервис, используемый тестируемым классом. Тесты супер-быстрые, все зеленое, все супер! Со временем я заметил, что этот подход начал изрядно напрягать. Часто на две строчки кода приходилось писать не менее 10-20 строк теста. Огромное количество переопределений поведения заглушек. Проверки, что тот или иной метод сторонних сервисов более не вызывался или вызывался не более определенного количества раз. Без преувеличения, огромное количество бойлер-плейт кода в тестах. Дальше - больше. Множество зеленых галочек, появляющееся на экране при запуске тестов, не могло не радовать. Но вот ты решаешь сделать минимальный рефакторинг, оптимизировать какой-то метод. В этот момент все разваливается на куски. Куча красных тестов! Как, почему? Да просто во внутренней реализации ты перешел на использование другого метода, добавил в метод еще один параметр или что-то подобное. В результате куча тестов перестала работать. И теперь, поменяв одну строчку в процессе рефакторинга, надо поменять еще 30 строк в тестах. В какой-то момент начинаешь ловить себя на мысли - “Ни в коем случае, никаких рефакторингов больше!” Вишенка на торте - ты выкатываешь приложение (нет, не в Прод, а только на тестовый стенд) и бац - ошибки. Приложение просто не стартует. Или стартует, но запрос, так успешно протестированный, ничего не возвращает. А ведь ты уже отчитался, что два дня писал тесты и все у тебя в порядке. В чем же дело? Дело в том, что дьявол кроется в деталях. Где-то перепутал нейминг в настройках, написал некорректный JPQL-запрос, огромное количество реальных деталей, ошибившись в которых, несмотря на пару сотен зеленых и быстрых юнит-тестов, у тебя ничерта не работает и совершенно непонятно, где искать проблему! Я начал искать выход из ситуации и нашел. В этом мне очень помогла потрясающая книга Владимира Хорикова “Принципы юнит-тестирования” и несколько видео от Victor Rentea, посвященных написанию тестов. Идея в том, что мы отходим от идеи тестировать наше приложение с изоляцией каждого отдельного класса, и переходим к тестированию нашего приложения по принципу “черного ящика”. Уходим от тестирования отдельных классов к тестированию конкретной функциональности приложения. То есть если наше приложение вызывают через REST с определенными параметрами, в результате чего в базе должна появиться определенная запись, то именно это мы и будем тестировать. Поднимем наше приложение, чтобы оно заработало, окружим его экземплярами вполне реальной инфраструктуры, вызовем его через REST и после проверим, что там в базе появилось. Да, это несколько дольше запускается, чем классические юнит-тесты. Зато такой подход очень устойчив к рефакторингу, позволяет выявить огромное количество неочевидных проблем, таких, к примеру, как ошибки в конфигурации продюсеров и консюмеров или REST-клиентов, ошибки в запросах SQL и JPQL, ошибки сериализации и так далее. Да просто обновите версию очередной библиотеки и если что-то пошло не так - вы это сразу увидите. Кроме того, так можно проверить действительно сложные головоломные кейсы, которые крайне сложно проверить на стандартных моках из Mockito. Дальше мы подробно разберем, что нужно сделать, чтобы поднять тестовое окружение, посмотрим несколько приемов для написания тестов. Хочу отметить, что решения, показанные в тестовом приложении, это не example и не tutorial. Это вполне production-ready реализация. Ключевая цель этого доклада - дать разработчику максимально готовый инструмент, которым он может воспользоваться быстро и максимально быстро преодолеть порог вхождения в тестирование с помощью Testcontainers. Надеюсь, что я с этой задачей справился, но решать вам :) Поехали! Тестовое приложениеИтак, вот что представляет из себя приложение, которое мы будем тестировать:
Так как подключение к базе у нас одно, то его настройки отдадим Spring Boot (через Приложение для примера: https://gitflic.ru/project/leva1981/testcontainers-variations Далее в докладе не будет встречаться вставок кода. Я так поступил не из лени, но из понимания того, что обрывки кода в моем случае просто замусорили бы текст статьи, при этом в отрыве от всего контекста их очень неудобно просматривать. Что и как будем тестироватьПодход к тестированию следующий:
Такие тесты пишем на все основные сценарии использования. На логику, не связанную с интеграциями или на сложно-тестируемые corner-case, напишем дополнительные init-тесты с использованием Mockito. Наша цель - покрыть интеграционными тестами все основные сценарии, а затем довести покрытие всех классов с логикой до 90-100%. Давайте прикинем, какое тестовое окружение нам надо поднять, чтобы наше приложение запустилось.
Поднимаем тестовое окружение
Собственно, если мы смогли запустить TestTestcontainersVariationsApplication, значит конфигурирование тестового окружения можно считать успешным. Теперь можно запускать TestTestcontainersVariationsApplication в debug-режиме, тыкать наше приложение палочкой снаружи и смотреть, что из этого выходит. Первый этап выполнен! Дополнительные приемыОставляем контейнеры работатьВ TestApplicationInitializer выставляем Обратите внимание на комментарий над константой REUSE_CONTAINERS - понадобится внести небольшое изменение в файл Из минусов - надо позаботиться о зачистке/обновлении данных в базе, а также самостоятельно погасить запущенные контейнеры, когда в них больше не будет нужды. Мягкий перезапуск через devtoolsТак как к проекту подключена зависимость Пишем тестыВся тестовая инфраструктура готова, теперь мы можем запустить наше приложение и дебажить его в свое удовольствие. Сделаем следующий шаг и перейдем к написанию тестов, которые будут тестировать приложение за нас.
Готово! Теперь можем запустить тесты через Идею с анализом тестового покрытия и оценить, насколько успешно мы покрыли наше приложение тестами. Как еще можноSpring way - поднимаем контейнеры через биныСуществует второй вариант поднять контейнеры в Spring-приложении - через бины. Я не стал его детально разбирать по ряду причин. Во-первых, не Спрингом единым, как говорится. Во-вторых, подход с использованием бинов несколько менее гибкий, особенно когда надо проинициализировать что-то сильно раньше поднятия контекста Спринга. Для того, чтобы перевести демонстрационное приложение на использование бинов тест-контейнеров, надо раскомментировать/закомментировать несколько строк в файлах TestTestcontainersVariationsApplication и AbstractIntegrationTest. В этом случае TestApplicationInitializer нам будет не нужен, для конфигурирования будут задействованы TestApplicationConfiguration и TestApplicationConfigurationMocks. Использование docker-composeМожно столкнуться с ситуацией, когда совершенно некогда возиться с тест-контейнерами, а приложение надо по-быстрому поднять локально и подебажить. К примеру, это может произойти, когда вам надо быстренько что-то поправить в микросервисе, за который отвечали не вы. В этом случае вы сможете быстренько поднять всю инфраструктуру через docker compose. Я сделал заготовки, которые должны максимально облегчить эту задачу. См. файлы в папке Оба файла (docker-compose.yml, docker-admin-ui.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. Использует скрипт docker-admin-ui.ymlФайл redis-ui: Интерфейс RedisInsight для управления Redis. Настроен с портом и переменными окружения для подключения к Redis. kafka-ui: Интерфейс Kafka UI для управления Kafka. Настроен с портом и переменными окружения для подключения к Kafka, включая имя кластера и адрес брокера. pgadmin: Интерфейс pgAdmin для управления PostgreSQL. Настроен с портом и переменными окружения для подключения к базе данных, включая электронную почту и пароль администратора. Также содержит конфигурационные файлы для серверов и подключения к базе данных. Получаем разрешение на освоение и внедрениеИтак, вы обогатились теоретическими знаниями. Теперь надо перевести знания в практическую плоскость и внедрить в один из сервисов вашего проекта (для начала). Хотелось бы делать это не в свободное время, а вполне официально. Вопрос, который вам сразу зададут, будет скорее всего звучать так - “Сколько времени тебе на это нужно?”. Давайте прикинем. На повторный анализ статьи, перенос кода в свой сервис, танцы с бубном при настройке и запуске - на это уйдет рабочий день. Еще один день уйдет на написание первого интеграционного теста. Потом вы переспите со всей этой новой информацией и, скорее всего, доработаете свой первый тест или напишите еще один. То есть может добавиться (или нет) еще один рабочий день. На этом этап освоения можно считать законченным. То есть на вопрос “Сколько времени тебе нужно?” можно смело отвечать “Два, максимум три, рабочих дня.” Срок адекватный, поэтому с большой вероятностью вы сможете завести себе задачу и разобраться с вопросом в рабочее время. Подведем итогиTestcontainers решает ту самую боль, с которой я столкнулся, имея дело с сотнями “зеленых” Mockito-тестов: все тесты проходят, но в реальности приложение не работает. Вместо того чтобы тестировать изолированные классы с кучей моков, мы тестируем настоящую функциональность с реальной PostgreSQL, Kafka и другими внешними сервисами. Конечно это медленнее, чем юнит-тесты, но зато когда тесты проходят - приложение действительно работает. Это плата за спокойствие. Вы перестанете бояться рефакторинга, вы сами (и ваша команда) перестанете спрашивать: “Почему тесты проходят и локально работает, а на стенде падает?” Это не просто инструмент - это смена подхода к тестированию, которая экономит часы отладки интеграционных проблем и дает настоящую уверенность в коде. Это реальная эволюция в подходе к качеству. Это шаг от вопроса “работает ли этот метод?” к вопросу “работает ли мое приложение?”. МатериалыПриложение: Использованные материалы:
АвторЛеонид Сухин, Java-разработчик |