Интеграционное тестирование с Testcontainers: запуск вашего реального WAR-файла в Docker

Источник: Azul | Better Java Performance, Superior Java Support•

Интеграционное тестирование с Testcontainers: запуск вашего реального WAR-файла в Docker

Testcontainers запускает временные Docker-контейнеры в рамках жизненного цикла вашего теста, поэтому интеграционные тесты выполняются с использованием реального WAR-файла на реальном экземпляре Azul Payara Micro.

Пустое поле librarianID в форме выдачи книг молча сохранялось как пустая строка вместо null. Это приводило к сбою проверки «сохранено ли это уже?» в выпадающем списке на совершенно другой странице. Ошибка существовала исключительно во взаимодействии между привязкой JSF «строка-к-свойству» и обратным вызовом @PrePersist — это означает, что никакое модульное тестирование с использованием фиктивного EntityManager не смогло бы её обнаружить. Она не существовала до тех пор, пока реальный браузер не отправил реальную форму в развернутое приложение.

Это тот класс дефектов, для обнаружения которых существует средний уровень набора тестов, и Testcontainers — это то, как вы этого добьетесь. К концу этой статьи вы узнаете, как запустить ваш фактически собранный WAR-файл на реальном экземпляре Azul Payara Micro изнутри теста JUnit и как избежать двух ошибок, которые делают эти тесты нестабильными.

Как это работает

Testcontainers — это Java-библиотека, которая запускает одноразовые Docker-контейнеры как часть жизненного цикла теста и удаляет их после завершения выполнения — будь то база данных, брокер сообщений или, как в случае с этим проектом, целый сервер приложений. Тест взаимодействует с контейнером через реальный сетевой порт, точно так же, как это делал бы пользователь или балансировщик нагрузки.

Вы получаете GenericContainer или специально созданный подкласс для распространенных технологий, таких как PostgreSQL или Kafka, который оборачивает Docker-образ. Вы настраиваете его — имя образа, открытые порты, файлы для копирования и стратегию ожидания, которая сообщает Testcontainers, как понять, что контейнер действительно готов — а затем вызываете start(). Testcontainers связывается с локальным демоном Docker, при необходимости скачивает образ, запускает контейнер, ждет выполнения условия готовности и возвращает хост и сопоставленный порт, необходимые для подключения. Вспомогательный контейнер под названием Ryuk запускается вместе с вашей сборкой и уничтожает контейнеры, даже если JVM завершается в середине теста, поэтому осиротевшие контейнеры не накапливаются на CI-раннере. (Ryuk можно отключить, и в некоторых ограниченных CI-средах это делается — стоит знать об этом, прежде чем предполагать, что очистка происходит автоматически.)

Библиотека подключается к JUnit 5 через модуль JUnit 5, который предоставляет аннотации @Testcontainers и @Container. Вы также можете управлять жизненным циклом контейнера вручную — например, запуская его один раз в статическом инициализаторе — когда эти аннотации не подходят для топологии вашего теста. Этот проект делает именно так, по причине, описанной ниже.

Что вы получаете

Основное преимущество — точность развертывания: вы тестируете то, что собираетесь поставлять, а не его имитацию. Контейнер, работающий на реальном образе payara/micro с развернутым внутри реальным WAR-файлом, собранным через Maven, выявит проблемы с упаковкой, отсутствующие зависимости и специфическое поведение среды выполнения, которое не сможет обнаружить ни один mock-объект.

На втором месте — воспроизводимость. Одно и то же определение контейнера работает идентично на ноутбуке и на CI-раннере, потому что в обоих случаях используется один и тот же образ. Нет разрыва «на моей машине работает», вызванного тем, что в одной среде установлена другая версия базы данных, чем в другой.

Это также не привязано к Jakarta EE или вообще к Java-серверам. Тот же самый шаблон — запустить реальную зависимость в Docker, направить на неё тест, остановить её — применяется к экземпляру PostgreSQL, брокеру Kafka, кэшу Redis или серверу приложений. Это универсальный навык интеграционного тестирования, который переносится между проектами и стеками.

И поскольку всё работает через примитивы сети и копирования файлов Docker, а не через специфический для фреймворка дескриптор развертывания, ментальная модель остается необычно доступной. «Запустить контейнер, обратиться к нему по сети» — это то, что большинство Java-разработчиков уже понимают благодаря использованию Testcontainers в других местах, даже если они никогда не видели этот конкретный проект.

Чего это вам стоит

Самая непосредственная цена — зависимость от Docker. Если CI-среда или машина разработчика не могут запустить среду выполнения контейнеров, эти тесты там не запустятся. Стоит проверить это заранее, а не обнаруживать во время релиза. Ограничение стало мягче, чем раньше — Testcontainers работает с Podman, Colima и другими rootless-средами, а также с Docker Desktop, а Testcontainers Cloud существует для сред, которые вообще не могут запускать контейнеры локально — но это всё еще жесткая зависимость, и @EnabledIfDockerAvailable — это изящный способ пропустить тест, а не провалить его, когда Docker отсутствует.

Далее — скорость. Загрузка реального сервера приложений внутри контейнера измеряется секундами, а не миллисекундами, которые требуются для модульного теста на основе WeldInitiator. Именно поэтому Testcontainers относится к уровням интеграционного и сквозного (end-to-end) тестирования, где более медленный цикл обратной связи является приемлемой ценой, а не к уровню, который разработчики запускают при каждом сохранении.

Эти тесты также по своей природе более нестабильны, чем чистые модульные тесты, просто потому, что задействовано больше движущихся частей: медленная загрузка образа, контейнер, которому требуется больше времени для готовности, чем ожидала стратегия ожидания, порт, которому требуется время для привязки. Хорошие стратегии ожидания и ограниченные повторные попытки хорошо справляются с этим, но риск сбоя по причине, не связанной с тестируемой функцией, никогда не сводится к нулю.

Наконец, Testcontainers доводит вас только до границы контейнера. Он доказывает, что развернутое приложение правильно отвечает на реальные HTTP-запросы и реальные взаимодействия браузера извне; он не дает вам возможности проверять состояние, управляемое контейнером, напрямую, как это делает внутриконтейнерный фреймворк, такой как Arquillian. Для большинства функциональных и регрессионных тестов внешний взгляд — это именно то, что вам нужно, но это реальное ограничение, и именно поэтому в этом проекте путь Arquillian сохраняется наряду с путем Testcontainers. Это тема следующей статьи в этой серии.

Тесты, которые это позволяет проводить

Testcontainers предоставляет вам интеграционные и сквозные тесты без необходимости поддерживать общую тестовую среду. На практике это охватывает три шаблона:

  • Полностековые интеграционные тесты, которые проверяют REST API через реальный HTTP для развернутого приложения, вместо вызова метода ресурса напрямую внутри процесса.
  • Сквозные тесты, управляемые браузером, где «безголовый» (headless) браузер управляет фактически отрисованным UI в контейнере — обнаруживая ошибки, которые существуют исключительно во взаимодействии между уровнем представления и сервером.
  • Интеграционные тесты для любой внешней зависимости, которая нужна реальному развертыванию — сам сервер приложений или база данных, очередь или кэш, с которыми он взаимодействует — без общей промежуточной среды (staging), которую кто-то должен постоянно обновлять и синхронизировать.

Как это используется в testcontainers-example

Интеграционный уровень проекта запускает реальный контейнеризированный экземпляр Azul Payara Micro. Зависимость является стандартным дополнением с областью видимости test в pom.xml:

Определение контейнера находится в PayaraContainer, подклассе GenericContainer, настроенном на получение образа payara/micro версии, под которую собирается проект, копирование WAR-файла, который только что создала сборка, и ожидание строки лога готовности Payara, прежде чем считать себя запущенным:

Обратите внимание на requiredProperty. Системные свойства payara.version и war.path не являются жестко закодированными, и контейнер выдает понятное сообщение об ошибке, если они отсутствуют — что неизбежно произойдет, если кто-то запустит тесты из IDE, а не через Maven. Сборка предоставляет их через systemPropertyVariables плагина Failsafe:

war.path указывает на WAR-файл, созданный Maven во время фазы package, поэтому контейнер всегда развертывает артефакт, который только что был собран, а не устаревшую версию.

Один контейнер на весь запуск: паттерн «одиночка»

Управление контейнером находится в AbstractContainerIT, общем базовом классе для каждого интеграционного теста в проекте:

Запуск контейнера в статическом инициализаторе, а не через жизненный цикл @Testcontainers/@Container в JUnit 5, является осознанным решением. Это задокументированный паттерн одиночного контейнера, и причина его использования здесь специфична: расширение JUnit управляет статическим полем @Container с логикой beforeAll/afterAll для каждого класса. Если поместить это поле в базовый класс, общий для многих тестовых классов, то первый завершившийся класс остановит контейнер в своем методе afterAll, оставив все последующие классы работать с нерабочим контейнером. Статический инициализатор полностью обходит расширение — один экземпляр Azul Payara Micro запускается один раз на весь процесс выполнения, и все классы интеграционных тестов используют его совместно. Очистка ресурсов ложится на Ryuk и завершение работы JVM, а не на JUnit.

Компромисс стоит обозначить прямо: общий контейнер означает общее состояние. Ничего не сбрасывается между тестами, поэтому тесты должны быть написаны так, чтобы не конфликтовать друг с другом. Подробнее об этом ниже.

Два типа тестов для одного контейнера

AbstractServiceIT расширяет AbstractContainerIT и настраивает вокруг него Jakarta REST Client, чтобы такие классы, как BookServiceIT, могли обращаться к развернутому API напрямую:

Это реальный POST-запрос по HTTP к ресурсу Jakarta REST, данные которого сохраняются через собственный источник данных среды выполнения. Стоит уточнить, что такое «база данных» в данном случае: приложение использует built-in embedded H2 datasource jdbc/__default от Azul Payara Micro, работающую внутри того же контейнера. Отдельного контейнера базы данных нет. Это подходит для проверки уровня персистентности, но это не та база данных, которая используется в продакшене — и устранение этого последнего пробела требует изменения всего в одну строку, поскольку добавление PostgreSQLContainer и перенаправление развертывания на него — это именно то, для чего предназначен Testcontainers.

Гонка при запуске и как предотвратить ее влияние на тесты

Цикл повторных попыток вокруг этого POST-запроса заслуживает подробного объяснения, так как это самая важная вещь, которую нужно понять при тестировании развернутого приложения таким образом.

PayaraMicroContainer ожидает строку лога «Payara Micro … ready», и метод start() возвращает управление, как только Testcontainers видит ее. Но эта строка означает лишь то, что сервер запущен и слушает порт 8080. Она ничего не говорит о том, завершилось ли развертывание WAR-файла, скопированного в /opt/payara/deployments, и зарегистрированы ли его ресурсы Jakarta REST. Существует короткий промежуток времени, когда контейнер «готов» согласно стратегии ожидания, TCP-соединение успешно, но Payara отвечает 404, так как ресурсы/books еще не существуют.

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

Более чистое решение — перенести эту терпимость в определение контейнера: добавить стратегию ожидания HTTP на конечную точку приложения после сообщения в логе, чтобы start() не возвращал управление до тех пор, пока само развертывание не начнет отвечать.

Установите общий тайм-аут запуска явно — WaitAllStrategy применяет один бюджет ко всем своим стратегиям, и медленная первая загрузка образа может его исчерпать. Цена такого подхода — связывание определения контейнера с известным путем приложения; преимущество — гонка при запуске обрабатывается в одном месте, а не обнаруживается заново в каждом новом тестовом классе. В любом случае основной тезис остается прежним: с Testcontainers «контейнер запущен» и «приложение обслуживает запросы» — это два разных события, и тесты, которые их смешивают, будут нестабильными при запуске.

Управление браузером для того же контейнера

AbstractUiIT расширяет тот же AbstractContainerIT, но управляет headless-браузером Chromium через Playwright для взаимодействия с JSF-страницами контейнера вместо вызова REST API:

Одна вещь, на которую стоит обратить внимание в этом фрагменте: assertThat здесь — это PlaywrightAssertions.assertThat, а не JUnit или AssertJ. Версия Playwright повторяет утверждение до тех пор, пока оно не пройдет или не истечет время ожидания, что делает UI-утверждения устойчивыми к асинхронному рендерингу — и если статический импорт конфликтует с другим assertThat, вы теряете это поведение повтора без каких-либо предупреждений компилятора.

Именно этот уровень обнаружил ошибку librarianID, упомянутую в начале этой статьи.

Поскольку экземпляр Payara в контейнере и его база данных H2 существуют на протяжении всего запуска, а не сбрасываются между тестами, AbstractUiIT предоставляет вспомогательный метод unique(prefix), который добавляет к тестовым данным случайный идентификатор. Таким образом, тесты остаются независимыми от того, что записали другие тесты, без необходимости каждый раз создавать новый контейнер. А расширение FailureDiagnostics на основе TestWatcher сохраняет скриншот, HTML страницы, ее URL и логи контейнера в target/playwright-failures/ при любой ошибке — поэтому упавший UI-тест можно отладить прямо из вывода CI, без необходимости воспроизводить его интерактивно на ноутбуке, где установлены и Docker, и браузер.

Эту последнюю деталь стоит скопировать, даже если вы больше ничего не возьмете из этого проекта. Главное практическое возражение против тестов уровня браузера заключается в том, что их ужасно отлаживать, когда они падают на чужой машине. Скриншот и лог сервера, создаваемые автоматически при сбое, снимают большую часть этого возражения.

Где это применимо

Testcontainers позволяет тестировать функции в их целевой среде, портативным и изолированным способом. Нет сервера, который нужно поддерживать: контейнер запускается из фиксированного образа, каждый раз создает одну и ту же среду и выполняет тесты против WAR-файла, который только что создала сборка. Затраты — это жесткая зависимость от среды выполнения контейнеров и время запуска, измеряемое секундами, а не миллисекундами — именно поэтому это относится к уровням интеграционного и сквозного (end-to-end) тестирования, выше быстрых CDI-модульных тестов, рассмотренных в первой части этой серии.

Первая часть этой серии охватывает уровень быстрых CDI-тестов, а третья часть (еще не опубликована) сравнивает все три инструмента — WeldInitiator, Arquillian и Testcontainers — бок о бок, включая случай, когда Testcontainers не может получить доступ извне контейнера.

Если ваши приложения Jakarta EE работают на Azul Payara Micro или Azul Payara Server, этот уровень стоит строить на основе той среды выполнения, которую вы реально поставляете, а не на ее аналогах.

Если вы хотите вынести одну главную мысль из вашего следующего обсуждения стратегии тестирования, пусть это будет следующая: успешный тест с использованием заглушки (mock) доказывает, что ваш код соответствует вашим предположениям, а успешный тест с использованием реального контейнера доказывает, что он соответствует производственной среде.

Часто задаваемые вопросы

Для чего используется Testcontainers при тестировании Java-приложений?

Testcontainers — это Java-библиотека, которая запускает временные Docker-контейнеры в рамках жизненного цикла теста и удаляет их после завершения, благодаря чему интеграционные тесты выполняются с использованием реальных зависимостей, а не заглушек — будь то база данных, брокер сообщений или полноценный сервер приложений. Для работы с Jakarta EE она позволяет запустить реальный WAR-файл, собранный Maven, на настоящей среде выполнения, такой как Azul Payara Micro, с доступом через реальный сетевой порт. Это позволяет выявить проблемы с упаковкой и развертыванием, которые невозможно обнаружить при внутрипроцессном тестировании.

Как запускать интеграционные тесты на реальном сервере приложений?

Оберните Docker-образ сервера в GenericContainer из Testcontainers, скопируйте собранный WAR-файл в директорию развертывания образа, объявите стратегию ожидания готовности и вызовите метод start(). После этого тест получит сопоставленные хост и порт и будет взаимодействовать с приложением по HTTP точно так же, как это делал бы клиент. В случае с Azul Payara Micro это означает загрузку образа payara/micro фиксированной версии и копирование WAR-файла в /opt/payara/deployments, при этом версия и путь к WAR-файлу передаются из сборки Maven, а не прописываются жестко.

Почему тесты Testcontainers возвращают ошибку 404 сразу после запуска контейнера?

Потому что «контейнер готов» и «приложение развернуто и обслуживает запросы» — это два разных события. Стратегия ожидания, отслеживающая строку лога запуска сервера, срабатывает, как только сервер начинает прослушивать порт, что может произойти за несколько секунд до того, как WAR-файл завершит развертывание и зарегистрирует свои эндпоинты. Запросы, отправленные в этот промежуток времени, получают ошибку 404, которая выглядит в точности как ошибка маршрутизации. Решение заключается в добавлении цепочки стратегий ожидания HTTP для известного эндпоинта приложения, чтобы метод start() не завершался до тех пор, пока развертывание действительно не начнет отвечать, вместо того чтобы разбрасывать циклы повторных попыток по отдельным тестам.

Что такое паттерн «одиночный контейнер» (singleton container) и когда он нужен?

Это запуск одного контейнера в статическом инициализаторе и его совместное использование всеми тестовыми классами в рамках одного запуска, вместо того чтобы позволить расширению JUnit 5 @Testcontainers управлять контейнером для каждого класса отдельно. Это необходимо в тех случаях, когда статическое поле контейнера находится в общем базовом классе: метод afterAll расширения, работающий на уровне класса, остановит контейнер после завершения первого тестового класса, из-за чего последующие классы будут обращаться к уже неработающему контейнеру. Компромисс заключается в том, что состояние между тестами не сбрасывается, поэтому тестовые данные должны быть уникальными, а не полагаться на их чистоту.

Нужны ли мне по-прежнему модульные тесты (unit tests), если у меня есть интеграционные тесты на Testcontainers?

Да. Интеграционные тесты занимают секунды, так как они запускают реальную среду выполнения в Docker, поэтому их нельзя запускать при каждом сохранении кода, к тому же они наблюдают за приложением только снаружи контейнера. Быстрые модульные CDI-тесты — использующие реальный контейнер Weld SE в тестовой JVM и выполняющиеся за миллисекунды — отвечают на вопрос, верны ли связывание бинов и бизнес-логика, и должны присутствовать в каждой сборке. Команды, поставляющие решения для Azul Payara Micro или Azul Payara Server, обычно используют все три уровня: CDI-модульные тесты при каждом сохранении, интеграционные тесты Testcontainers и браузерные тесты в CI, а также внутриконтейнерные тесты, когда необходимо напрямую проверять внутреннее состояние.

О чём эта статья

Ещё в разделе «Разработка ПО»

Все →

Ещё от Azul