В примерном проекте этой серии объявлено четырнадцать аннотаций @NamedQuery в классах доменной модели. До недавнего времени ни одна из них никем не вызывалась — ни REST-ресурсом, ни JSF-бином, ни тестом. Ничто никогда не подтверждало, что их JPQL компилируется, не говоря уже о возврате правильных строк. То же самое касалось методов count() и findRange() в базовом классе общего сервиса.
Этот пробел существовал не из-за чьей-то небрежности. Он возник потому, что в проекте были модульные тесты и развернутые сквозные тесты, а этот конкретный код не был доступен ни тем, ни другим. Для его устранения потребовался третий инструмент. В этой статье сравниваются все три — WeldInitiator, Arquillian и Testcontainers — на одной и той же кодовой базе, чтобы вы могли понять, на какой вопрос отвечает каждый из них и какие пробелы это оставляет.
Тестирование приложения Jakarta EE затруднительно по конкретной причине: поведение, которое вас интересует, часто не существует, пока контейнер его не соберет. Если вы все замените заглушками (mock), вы получите тесты, которые проходят, но почти ничего не доказывают. Если вы развернете все целиком, вы получите медленные, сложные сценарии, которые никто не запускает во время разработки. Три представленных здесь инструмента находятся на разном удалении от кода, и полезный вопрос заключается не в том, какой из них лучше, а в том, какое расстояние требуется для конкретного теста.
Пример представляет собой , небольшую библиотечную систему управления, созданную с помощью Payara Starter и работающую на Azul Payara Micro. Ее тесты написаны так, чтобы избежать избыточности, поэтому каждый инструмент покрывает то, что не могут другие.
Три инструмента, три вопроса
WeldInitiator — это инструмент для модульного тестирования: работает ли собственная логика этого бина и его CDI-связи в изоляции, без контейнера и без внешних зависимостей? Arquillian — это инструмент интеграционного тестирования внутри контейнера: ведет ли себя этот код правильно при выполнении внутри JVM реального контейнера, взаимодействуя с фактическим провайдером персистентности? Testcontainers охватывает интеграционное и сквозное тестирование: работает ли полностью собранное, развернутое приложение снаружи, будь то вызов его REST API или управление его пользовательским интерфейсом в реальном браузере?
Это заключительная часть серии «Тестирование Jakarta EE: три уровня, три инструмента». Первая часть посвящена WeldInitiator и уровню модулей CDI; вторая часть посвящена Testcontainers и уровню развертывания. В этой статье все три инструмента рассматриваются вместе.
WeldInitiator: работают ли CDI-связи и бизнес-логика в изоляции?
WeldInitiator из модуля weld-junit5 запускает контейнер Weld SE внутри JVM теста. Вы указываете, какие бины включить в контейнер, Weld разрешает и внедряет их CDI-зависимости, и весь цикл завершается за миллисекунды.
Каждый *ServiceTest класс в проекте — BookServiceTest, PatronServiceTest, LibrarianServiceTest, LoanServiceTest — построен именно так:
Преимущества. Это самый быстрый уровень с большим отрывом: нет контейнера для запуска, нет сети, нет Docker. Поскольку weld.select(BookService.class).get() возвращает экземпляр, управляемый контейнером, тест обнаруживает регрессию связей — нарушенную точку @Inject, измененную область видимости — так же, как это было бы в продакшене, в отличие от ручного создания экземпляра через new BookService(). Ограничение WeldInitiator.from(…) только тестируемым бином делает сбои локальными и легко диагностируемыми.
Ограничения. Это доказывает, что CDI-связи и бизнес-логика верны, и ничего более. Контейнер Weld SE — это не контейнер Jakarta EE: он не выполняет внедрение @PersistenceContext и не применяет контейнерные перехватчики @Transactional, поэтому заглушка EntityManager оставляет JPA-маппинги, сгенерированный SQL, реальное поведение транзакций и именованные запросы полностью непроверенными. Собственный тест Arquillian в проекте прямо говорит об этом в комментарии к классу: ни один из методов count(), findRange() или именованных запросов AbstractService не проверяется ни одним *ServiceTest, потому что за заглушкой нет реального провайдера персистентности, против которого их можно было бы запустить.
Внедрение полей через @PersistenceContext также не оставляет чистого интерфейса для Mockito. Поле em в AbstractService является приватным, без сеттера и параметра конструктора, поэтому нет публичной точки входа, через которую тест мог бы передать заглушку — именно то, что обычно используют @InjectMocks или внедрение через конструктор. Не имея такой точки, тест обходит инкапсуляцию: после того как Weld создает бин, он находит приватное поле через рефлексию и принудительно вставляет заглушку. Это работает, но обходит API класса, а не использует его. Регистрация бина-заглушки через .addBeans(MockBean.of(…)) является более чистой альтернативой, если соавтор может быть создан как CDI-бин.
Лучше всего подходит для: быстрой, TDD-дружественной проверки логики CDI-управляемых бинов и регрессионных тестов для самого внедрения зависимостей.
Arquillian: работает ли этот фрагмент контейнерно-управляемого поведения изнутри JVM контейнера?
Arquillian меняет подход двух других инструментов. Вместо тестирования снаружи как «черного ящика», он переносит метод @Test внутрь контейнера. Вы описываете развертывание с помощью ShrinkWrap, Arquillian развертывает его в реальный контейнер, и благодаря обогащению байт-кода метод теста выполняется внутри JVM этого контейнера — с реальными CDI-бинами, корпоративными бинами и ресурсами JTA, внедренными непосредственно в тест.
Этот проект использует Arquillian намеренно узко, ровно в одном классе: . Он охватывает ту часть AbstractService, до которой не могут дотянуться ни BookServiceTest, ни BookServiceIT: count(), findRange() и методы именованных запросов, которые не вызывает ни один REST-ресурс, JSF-бин или другой тест.
О развертывании: это минимальный JavaArchive, содержащий только доменные классы, AbstractService и BookService, реальный persistence.xml и пустой beans.xml — не полный WAR, который развертывает Testcontainers, потому что этому тесту нужно только то, чтобы BookService был внедряем в работающий блок персистентности, а не уровни Jakarta REST или JSF.
Адаптер Payara Micro Managed (fish.payara.arquillian:arquillian-payara-micro-managed) запускает Azul Payara Micro как обычный процесс ОС, независимо от Docker-образа, который Testcontainers скачивает для той же версии. arquillian.xml настраивает параметры запуска — случайный HTTP-порт, автоматическую привязку и 180-секундный тайм-аут запуска — в то время как путь к самому JAR-файлу Payara Micro передается как системное свойство payara.microJar из сборки Maven.
Преимущества. Arquillian — единственный из трех, который может выполнить @Inject реального бина прямо в метод теста и проверить его работу с реальным провайдером персистентности, без промежуточных уровней REST или UI. В этом проекте это заполняет реальный пробел, поскольку методы count(), findRange() и именованные запросы не имеют других вызывающих сторон в кодовой базе для их проверки. Развертывания ShrinkWrap также могут быть строго ограничены только теми классами, которые нужны тесту, что сохраняет область сбоев узкой даже внутри полноценного контейнера.
Ограничения. Метод @Deployment — это второе, поддерживаемое вручную описание упаковки, и ничто не гарантирует, что оно соответствует тому, что содержит реальный WAR-файл — именно этого расхождения Testcontainers избегает, развертывая сам артефакт сборки. Запуск занимает секунды, поскольку должен загрузиться настоящий сервер приложений. А Arquillian требует от читателя большего, чем два других инструмента: сборка архива ShrinkWrap плюс конфигурация контейнера arquillian.xml — это, по сути, специализированный DSL, наложенный поверх Jakarta EE, поэтому данный проект ограничивает его использование одним классом, а не делает его общей стратегией.
Перед тем как копировать эту настройку, стоит учесть три практических предостережения:
- Версия адаптера в этом проекте — 4.0.alpha4. Он работает, но это альфа-версия — зафиксируйте её намеренно и будьте готовы вернуться к этому вопросу.
- Файл pom исключает org.jboss.weld и org.jboss.weld.se из зависимостей адаптера. Без этих исключений Weld адаптера добавляет вторую реализацию CDI в classpath теста, где уже есть weld-junit5, и два стека тестирования конфликтуют. Если вы запускаете WeldInitiator и Arquillian в одном модуле, будьте готовы к необходимости управления этим конфликтом.
- JAR-файл Payara Micro, который запускает управляемый адаптер (Managed adapter), копируется в папку target/ профилем Maven install-deps. Запустите тест Arquillian без выполнения этого профиля, и он завершится ошибкой из-за отсутствующего файла, а не из-за вашего кода — это тот же профиль, который устанавливает браузеры Playwright.
Альтернативой является удаленный адаптер (Remote adapter): укажите Arquillian на экземпляр Azul Payara Micro или Azul Payara Server, который вы запускаете и останавливаете самостоятельно. Это меняет время запуска на выполнение на настройку и очистку, которые вы контролируете сами. В любом случае, эти тесты выполняются секунды, а не миллисекунды.
Лучше всего подходит для: внутриконтейнерных проверок поведения, которое не охватывается никаким другим уровнем — тест, который вы пишете после того, как выявили пробел между модульными тестами и развернутыми тестами.
Testcontainers: действительно ли развернутое приложение работает?
Testcontainers запускает Docker-контейнеры как часть жизненного цикла теста — здесь это образ payara/micro с развернутым внутри WAR-файлом, собранным Maven. Ничего не имитируется (mock); тест взаимодействует с реально работающим приложением по сети, так же, как это делал бы пользователь или REST-клиент.
PayaraMicroContainer оборачивает образ, а AbstractContainerIT запускает его ровно один раз в статическом инициализаторе, общем для всех классов интеграционных тестов:
Две категории тестов совместно используют этот контейнер. проверяет развернутый REST API по протоколу HTTP:
А BookUiIT управляет JSF-страницами с помощью headless-браузера Chromium через Playwright:
Этот assertThat — это PlaywrightAssertions.assertThat, который повторяет попытку до тех пор, пока утверждение не пройдет или не истечет время ожидания — именно это делает UI-утверждения устойчивыми к асинхронному рендерингу. Статический импорт из JUnit или AssertJ скомпилируется так же успешно, но молча потеряет это поведение.
Преимущества. Точность развертывания — главное преимущество: war.path в системных свойствах плагина Failsafe указывает на WAR-файл, который только что был создан на этапе package, поэтому нет отдельного архива, поддерживаемого вручную, который мог бы разойтись с тем, что поставляется. Этот шаблон также не является специфичным для Jakarta EE — тот же подход запускает экземпляр PostgreSQL или брокер Kafka.
Ограничения. Он требует наличия среды выполнения контейнеров без исключений. Выполнение измеряется секундами, потому что должен загрузиться настоящий сервер приложений. И он наблюдает только снаружи: он подтверждает, что развернутое приложение правильно реагирует на HTTP и взаимодействие с браузером, но не предлагает способа напрямую проверить состояние, управляемое контейнером.
Лучше всего подходит для: интеграционных тестов по HTTP для развернутого REST API и сквозных (end-to-end) браузерных тестов для полностью развернутого приложения.
Почему они не взаимозаменяемы: один конкретный случай
Самая наглядная иллюстрация во всем проекте — это пара тестов для одного метода findOrEmpty(), который преобразует NoResultException из JPA в Optional.empty().
Модульный тест WeldInitiator имитирует (mock) EntityManager, чтобы тот выбрасывал NoResultException, а затем проверяет, что findOrEmpty() возвращает пустой Optional. Этот тест проходит. Он доказывает, что обработка ошибок верна — при условии, что NoResultException получен.
Тест Arquillian выполняет реальный именованный запрос к реальному провайдеру персистентности для ISBN, которого не существует, и проверяет тот же пустой Optional. Этот тест доказывает то, чего не может доказать модульный тест: что действительно пропущенный запрос на самом деле приводит к возникновению NoResultException, а не пустого списка, null или совершенно другого типа исключения.
Один тест проверяет вашу обработку предположения. Другой проверяет само предположение. Оба проходят, оба полезны, и ни один не заменяет другой. Это и есть главный аргумент в пользу того, чтобы рассматривать эти инструменты как уровни, а не как альтернативы.
Тест findRange() в проекте подтверждает это в контексте написания честных утверждений на этом уровне. AbstractService#findRange строит свой CriteriaQuery без явного ORDER BY, поэтому спецификация JPA не гарантирует, какие строки попадут на какую страницу — только то, что постраничный вывод не дублирует и не теряет строки. Тест проверяет непересекаемость страниц, а не их содержимое, что является разницей между тестом, который документирует реальное поведение, и тестом, который проходит на вашей машине, потому что H2 случайно вернул строки в порядке вставки.
Бок о бок
Выбор между ними
На практике решение почти механическое. Если вопрос касается собственной логики бина или его связей CDI, используйте WeldInitiator и укладывайтесь в миллисекунды. Если вопрос в том, правильно ли ведет себя развернутое приложение снаружи — REST-контракт, отправка формы, отрендеренная страница — используйте Testcontainers. Обращайтесь к Arquillian в узких случаях, когда поведение имеет значение, не имеет вызывающей стороны, до которой может дотянуться любой другой уровень, и требует реального сервиса контейнера, такого как провайдер персистентности, чтобы иметь хоть какой-то смысл.
Если прочитать это правило в обратном направлении, оно станет аудитом покрытия. Код, до которого не может дотянуться ни один модульный тест, потому что ему нужен реальный провайдер, и до которого не может дотянуться ни один развернутый тест, потому что его никто не вызывает, невидим для обоих уровней — именно так четырнадцать именованных запросов остаются непроверенными в проекте, который выглядит хорошо протестированным.
Фраза, которую стоит запомнить для следующего обсуждения стратегии тестирования: проходящий тест с использованием моков доказывает, что ваш код соответствует вашим предположениям; только реальный контейнер доказывает, что предположения соответствуют реальности.
Часто задаваемые вопросы
В чем разница между Arquillian и Testcontainers?
Arquillian запускает метод теста внутри JVM контейнера, используя обогащение байт-кода, поэтому реальные CDI-бины, корпоративные бины и JTA-ресурсы могут быть внедрены прямо в тест, а внутреннее состояние проверено напрямую. Testcontainers запускает приложение в Docker-контейнере и тестирует его снаружи по сети, используя фактический артефакт сборки. Arquillian требует написанного вручную развертывания ShrinkWrap, которое может разойтись с реальной упаковкой; Testcontainers развертывает WAR, который только что создала сборка, но не может заглянуть внутрь контейнера. На Azul Payara Micro доступны оба варианта, и большинству проектов нужны оба.
Какой инструмент тестирования следует использовать для приложения Jakarta EE?
Это зависит от того, на какой вопрос должен ответить тест. Для проверки собственной логики бина и связывания CDI, WeldInitiator с weld-junit5 запускает настоящий CDI-контейнер в JVM теста за миллисекунды. Чтобы проверить, работает ли развернутое приложение по HTTP или в браузере, Testcontainers запускает собранный WAR-файл в реальной среде выполнения, такой как Azul Payara Micro. Для поведения, требующего реального сервиса контейнера, к которому не может обратиться ни один из этих уровней — пагинация, подсчеты, именованные запросы — Arquillian внедряет бин непосредственно внутрь контейнера. Это взаимодополняющие уровни, а не конкурирующие варианты.
Можно ли тестировать именованные запросы JPA без развертывания всего приложения?
Да, с помощью внутриконтейнерного теста и узкоспециализированного развертывания. ShrinkWrap от Arquillian может создать архив, содержащий только классы сущностей, класс сервиса и реальный persistence.xml, затем развернуть его в Azul Payara Micro и внедрить сервис в тест — этого достаточно, чтобы именованный запрос выполнился с реальным провайдером персистентности без уровней Jakarta REST или JSF. Mock-объект EntityManager не может этого сделать: он никогда не выполняет JPQL, поэтому запрос, который не компилируется, все равно проходит модульный тест.
Почему мои тесты проходят с mock-объектом EntityManager, но падают в продакшене?
Потому что mock возвращает то, что вы ему приказали вернуть. Модульный тест, который подменяет EntityManager для выброса NoResultException, доказывает, что ваша обработка ошибок верна при возникновении этого исключения; он никогда не доказывает, что реальный запрос выдаст именно это исключение, а не пустой список или другой сбой. Все, что зависит от фактического выполнения провайдером персистентности — корректность JPQL, маппинги, сгенерированный SQL, границы транзакций, пагинация — требует реального провайдера. В проекте Jakarta EE это означает внутриконтейнерный тест с Arquillian или тест развертывания в среде выполнения, такой как Azul Payara Micro.
Насколько интеграционные тесты медленнее модульных в Java?
Примерно на три порядка. Модульный CDI-тест в контейнере Weld SE внутри JVM теста завершается за миллисекунды; тест Arquillian или Testcontainers должен запустить сервер приложений, и его время измеряется секундами — пример проекта допускает 180-секундный тайм-аут запуска для управляемого экземпляра Azul Payara Micro. Именно из-за этой разницы уровни имеют разное назначение: модульные тесты запускаются при каждом сохранении, а интеграционные и сквозные тесты — в CI.












