
Helidon отлично подходит для создания микросервисов, для простого и быстрого развертывания в проде, и демострирует действительно впечатляющую производительность!
А как насчет тестирования Helidon?
В этой статье мы рассмотрим несколько способов, как это сделать.
Предмет тестирования
Давайте посмотрим на приложение, которое мы собираемся протестировать сегодня. Это упрощенная версия нашего большого приложения Socks Shop, доступного здесь . У некоторых есть PetClinic, а у нас вот Socks Shop!
Классов всего несколько штук, но этого должно быть достаточно, чтобы продемонстрировать все, что мы хотим протестировать. Мы сделаем только REST API без пользовательского интерфейса. Упростим все максимально!
Итак, у нас есть простое Helidon MP приложение, в котором создан SockShopResource с простой функцией покупки носков. Есть метод чтобы получить все носки с их ценами. И есть метод POST, который принимает корзину с выбранными товарами. После оформления заказа, в службу доставки отправляется сообщение с запросом отослать товар. Есть и инвоисинг сервис для подготовки и хранения счетов, он будет вызываться через REST. Все сервисы сами заботятся о сохранении своих данных (для этого используется обычный Hibernate:)).

Давайте теперь взглянем на каждый компонент, чтобы определить лучшую стратегию для его тестирования.
SockShopResource
Ресурс Socks Shop - это типичный REST endpoint, предоставляющий возможность оформления заказа:
private ShoppingService shoppingService; @Inject public SockShopResource(ShoppingService shoppingService) { this.shoppingService = shoppingService; } @POST public Response checkout(ShoppingCart shoppingCart){ long id = shoppingService.checkout(shoppingCart); UriBuilder responseUri = UriBuilder.fromResource(SockShopResource.class); responseUri.path(Long.toString(id)); return Response.created(responseUri.build()).build(); }
ShoppingService
Класс реализует процесс оформления заказа. Сервис получает корзину покупок:
@ApplicationScoped public class ShoppingService { private final SubmissionPublisher<String> emitter = new SubmissionPublisher<>(); @PersistenceContext(unitName = "test") private EntityManager entityManager; @Inject @RestClient private InvoicingClient invoicingClient; @Transactional public long checkout(ShoppingCart shoppingCart){ entityManager.persist(shoppingCart); Long id = shoppingCart.getId(); emitter.submit(String.valueOf(id)); invoicingClient.handleInvoice(shoppingCart); return id; } @Outgoing("outgoing-delivery") public Publisher<String> preparePublisher() { // Create new publisher for emitting to by this::process return ReactiveStreams .fromPublisher(FlowAdapters.toPublisher(emitter)) .buildRs(); } }
... и как только выполняется чекаут, в службу доставки отправляется сообщение, чтобы упаковать носки и отправить их Клиенту. Также вызывается инвойсинг сервис с помощью MicroProfile «RestClient», для выставления счета.
Служба доставки
Всякий раз, когда приходит сообщение о новой покупке, сервис доставки должен его обработать. Аннотация @Incomming указывает на метод, который ее обрабатывает.
@ApplicationScoped public class DeliveryService { @PersistenceContext(unitName = "test") private EntityManager entityManager; @Incoming("incoming-delivery") @Transactional public void deliverToCustomer(String cartId){ Delivery delivery = new Delivery(); delivery.setShoppingCartId(Long.parseLong(cartId)); entityManager.persist(delivery); } }
Есть так же методы проверки статуса доставки :)
Конфигурация
… она очень простая. Нам нужны только параметры подключения к БД и настройка messaging-a. Мы будем использовать H2 DB и ActiveMQ:
#Database javax.sql.DataSource.test.dataSourceClassName=org.h2.jdbcx.JdbcDataSource javax.sql.DataSource.test.dataSource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false javax.sql.DataSource.test.dataSource.user=sa javax.sql.DataSource.test.dataSource.password= #Messaging mp.messaging.connector.helidon-jms.jndi.env-properties.java.naming.provider.url=vm://localhost?broker.persistent=false mp.messaging.connector.helidon-jms.jndi.env-properties.java.naming.factory.initial=org.apache.activemq.jndi.ActiveMQInitialContextFactory mp.messaging.incoming.incoming-delivery.connector=helidon-jms mp.messaging.incoming.incoming-delivery.type=queue mp.messaging.incoming.incoming-delivery.destination=delivery mp.messaging.outgoing.outgoing-delivery.connector=helidon-jms mp.messaging.outgoing.outgoing-delivery.type=queue mp.messaging.outgoing.outgoing-delivery.destination=delivery
Нам этого достаточно!
Эти настройки записываем в microprofile-config.propertiesфайл. Стоит отметить, что эта конфигурация очень portable.
Некоторые другие файлы
AppInitialiser Класс используется для заполнения тестовых данных и запуска messaging-a:
@ApplicationScoped public class AppInitializer { @PersistenceContext(unitName = "test") private EntityManager entityManager; @Transactional void onStartup(@Observes @Initialized(ApplicationScoped.class) final Object event) { Socks model1 = new Socks(1L, "Model1", 10.00); entityManager.persist(model1); Socks model2 = new Socks(2L, "Model2", 20.00); entityManager.persist(model2); Client client1 = new Client(1L, "Joe", "Doe", "Somewhere", "12345"); entityManager.persist(client1); ShoppingCart cart = new ShoppingCart(); cart.setId(1L); cart.setClient(client1); cart.setCart(List.of(model1, model2)); entityManager.persist(cart); entityManager.flush(); } private void makeConnections(@Observes @Priority(PLATFORM_AFTER + 1) @Initialized(ApplicationScoped.class) Object event) throws Throwable{ ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory("vm://localhost?broker.persistent=false"); Connection connection = connectionFactory.createConnection(); connection.createSession(false, 1); } }
Здесь мы ожидаем моментa, когда все ApplicationScopedbean-компоненты запущены и работают. Как только event об этом выстрелил, мы можем заполнить данные. Это чистый CDI. Этот код на 100% portable.
Теперь все это надо протестировать!
Unit testing
Это, наверное, самое простое тестирование, но без него никак!
Все, что требует работы с БД или любыми внешними службами, требующими инициализации или работающими внутри контейнера CDI, можно легко заменить моками. Воспользуемся mockitoдля этого:
@ExtendWith(MockitoExtension.class) public class SockShopResourceMockTest { private List<Socks> socksList = List.of(new Socks(1L, "Model1", 10.00), new Socks(2L, "Model2", 20.00)); @InjectMocks private SockShopResource sockShopResource; @Mock private ShoppingService shoppingService; @BeforeEach private void init() { Mockito.lenient().doCallRealMethod().when(shoppingService).allSocks(); } @Test void allSocksTest() { Mockito.doReturn(socksList).when(shoppingService).allSocks(); String response = sockShopResource.allSocks(); assertEquals(response, "[{\"id\":1,\"model\":\"Model1\",\"price\":10.0},{\"id\":2,\"model\":\"Model2\",\"price\":20.0}]"); Mockito.verifyNoMoreInteractions(shoppingService); } }
Как видите, ShoppingServiceэто мок, и мы тестируем SockShopResource независимо от базовой инфраструктуры. Такие тесты выполняются очень быстро, поскольку на самом деле сервер не запущен.
@HelidonTest
Хорошо, теперь давайте углубимся и посмотрим, как протестировать функциональность внутри запущенного Helidon.
Начнем с наиболее часто используемой аннотации - @HelidonTest.Она берет на себя все заботы о запуске инициализации контейнера и подключении всего.
@HelidonTest public class TestSocksResource { @Inject WebTarget webTarget; @Test void testAllSocks(){ JsonArray jsonObject = webTarget.path("/api/shop/allSocks") .request() .get(JsonArray.class); assertEquals("[{\"model\":\"Model1\",\"price\":10.0},{\"model\":\"Model2\",\"price\":20.0}]",jsonObject.toString()); } }
Как видите, с помощью всего одной аннотации мы фактически запускаем Helidon MP, все в контейнере CDI инициализируется и инжектится. Так что перед непосредственно перед вызовом тестовых методов сервер работает. Мы можем использовать WebTargetдля тестирования наших REST ендпоинтов.
С дополнительными аннотациями, такими, как например:
@DisableDiscovery @AddExtension (ConfigCdiExtension.class) @AddBean (SocksTest.ConfiguredBean.class) @AddConfig (key = "test.message", value = "Socks!")
... тесты можно настраивать!
Подробнее об использовании этих аннотации вы можете прочитать в блог посте https://medium.com/helidon/testing-helidon-9df2ea14e22
Интеграционное тестирование с использованием Testcontainers
Testcontainers - это по-настоящему шедевральная технология, которая выводит интеграционное тестирование на новый уровень. С их помощью мы можем протестировать Helidon, работающий внутри контейнера:
protected static final String NETWORK_ALIAS_APPLICATION = "application"; protected static final Network NETWORK = Network.newNetwork(); protected static final GenericContainer<?> APPLICATION = new GenericContainer<>("socks-shop:latest") .withExposedPorts(8080) .withNetwork(NETWORK) .withNetworkAliases(NETWORK_ALIAS_APPLICATION) .withEnv("JAVA_OPTS", "-Djava.net.preferIPv4Stack=true -Djava.net.preferIPv4Addresses=true") .waitingFor(Wait.forHealthcheck()); static { APPLICATION.start(); }
Generic Container вполне подходит для этого. Для создания докер образа мы можем использовать docker-maven-pluginот Spotify:
<plugin> <groupId>com.spotify</groupId> <artifactId>dockerfile-maven-plugin</artifactId> <version>1.4.13</version> <configuration> <repository>${project.build.finalName}</repository> <buildArgs> <JAR_FILE>${project.build.finalName}.jar</JAR_FILE> </buildArgs> <skipDockerInfo>true</skipDockerInfo> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>build</goal> </goals> </execution> </executions> </plugin>
Теперь, когда у нас есть приложение, упакованное, как докер образ и обернутое как Testcontainer, мы можем провести полномасштабное интеграционное тестирование.
Хорошим помощником для этого является фреймворк Cucumber.
Давайте создадим очень простой сценарий покупки носков:
Feature: BuySocks Scenario: Buy one pair of socks Given a user makes a checkout When the checkout is performed Then submitted to delivery
Теперь покажем Cucumber, откуда читать файлы функций:
@RunWith(Cucumber.class) @CucumberOptions(plugin = {"pretty"}, features = "src/test/resources/it/feature") public class SocksShopCucumberIT { ... }
И так, мы готовы протестировать наше приложение, работающее в тестовом контейнере. Сначала запустите его:
@Before public void beforeScenario() { APPLICATION.withLogConsumer(new Slf4jLogConsumer(LOG)); requestSpecification = new RequestSpecBuilder() .setPort(APPLICATION.getFirstMappedPort()) .build(); }
Теперь выполним первый шаг сценария:
@Given("a user makes a checkout") public void a_user_makes_a_checkout() { Socks socks = new Socks(100l, "Model1", 10.0); Client client1 = new Client(100L, "Joe", "Doe", "Somewhere", "12345"); ShoppingCart shoppingCart = new ShoppingCart(); shoppingCart.setId(100L); shoppingCart.setClient(client1); shoppingCart.setCart(List.of(socks)); RestAssured.given(requestSpecification) .contentType(MediaType.APPLICATION_JSON) .body(shoppingCart) .when() .post("/api/shop/") .then() .statusCode(Response.Status.CREATED.getStatusCode()); }
Затем мы можем проверить, выполняется ли оформление заказа:
@When("the checkout is performed") public void the_checkout_is_performed() { RestAssured.given(requestSpecification) .accept(MediaType.APPLICATION_JSON) .when() .get("/api/shop/status/100") .then() .statusCode(Response.Status.OK.getStatusCode()) .contentType(MediaType.APPLICATION_JSON) .body(Matchers .equalTo("{\"cart\":[{\"id\":100,\"model\":\"Model1\",\"price\":10.0}],\"client\":{\"address\":\"Somewhere\",\"firstName\":\"Joe\",\"id\":100,\"lastName\":\"Doe\",\"postcode\":\"12345\"},\"id\":100}")); }
… И, наконец, мы можем проверить, было ли отправлено сообщение о доставке, и покупка оформлена для доставки:
@Then("submitted to delivery") public void submitted_to_delivery() throws InterruptedException { Thread.sleep(500);//wait the message to arrive RestAssured.given(requestSpecification) .when() .get("/api/delivery/status/100") .then() .statusCode(Response.Status.OK.getStatusCode()) .contentType(MediaType.APPLICATION_JSON) .body(Matchers .equalTo("{\"id\":1,\"shoppingCartId\":100}")); }
Когда пользователь запускает этот сценарий, происходит следующее:
Testcontainers берет самый свежий образ
socks-shopприложения и запускает его;Socks-shopзагружается и инициализацилируется внутри контейнера;Когда приложение заработает, вызывается `a_user_makes_a_checkout ()`;
Если все отработало штатно, the_checkout_is_performed() выполнится для проверки того, что заказ персистится правильно.
И, наконец, the_checkout_is_performed() проверяет, завершен ли заказ.
Тест считается успешным, если все этапы пройдены.
Поскольку мы используем in memory H2 и in memory Messaging, никаких дополнительных настроек не требуется.
Таким образом, вы можете протестировать свои приложения Helidon в «почти продакшн» среде.
…и наоборот
С тестовыми контейнерами и Helidon мы также можем сделать наоборот. Для интеграционного тестирования, мы можем заставить Helidon потреблять некоторые ресурсы от внешних служб, работающих внутри тестовых контейнеров.
Например, мы хотим проверить, хороро ли наше приложение работает с MariaDB в качестве базы данных и Kafka для обмена сообщениями. Они будут работать внутри тестовых контейнеров. Нам для этого нужно только чуть изменить конфигурацию.
Давайте снова используем аннотацию @HelidonTest для запуска и инициализации Helidon. Но поскольку конфиг необходимо переопределить, мы должны сообщить об этом с помощью параметра аннотации:
@Configuration(useExisting = true)
Теперь мы можем выполнить настройку контейнеров MariaDB и Kafka и объявить свойства:
private static MariaDBContainer<?> db = new MariaDBContainer<>("mariadb:10.3.6") .withDatabaseName("mydb") .withUsername("test") .withPassword("test"); static KafkaContainer kafka = new KafkaContainer(); @BeforeAll public static void setup() { kafka.start(); Map<String, String> configValues = new HashMap<>(); configValues.put("mp.initializer.allow", "true"); configValues.put("mp.messaging.incoming.from-kafka.connector", "helidon-kafka"); configValues.put("mp.messaging.incoming.from-kafka.topic", "delivery"); configValues.put("mp.messaging.incoming.from-kafka.auto.offset.reset", "latest"); configValues.put("mp.messaging.incoming.from-kafka.enable.auto.commit", "true"); configValues.put("mp.messaging.incoming.from-kafka.group.id", "helidon-group-1"); configValues.put("mp.messaging.outgoing.to-kafka.connector", "helidon-kafka"); configValues.put("mp.messaging.outgoing.to-kafka.topic", "delivery"); configValues.put("mp.messaging.outgoing.test-delivery.connector", "helidon-kafka"); configValues.put("mp.messaging.outgoing.test-delivery.topic", "delivery"); configValues.put(KafkaConnector.CONNECTOR_PREFIX + "helidon-kafka.bootstrap.servers", kafka.getBootstrapServers()); configValues.put(KafkaConnector.CONNECTOR_PREFIX + "helidon-kafka.key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); configValues.put(KafkaConnector.CONNECTOR_PREFIX + "helidon-kafka.value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); configValues.put(KafkaConnector.CONNECTOR_PREFIX + "helidon-kafka.key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); configValues.put(KafkaConnector.CONNECTOR_PREFIX + "helidon-kafka.value.deserializer", "org.apache.kafka.common.s" + "erialization.StringDeserializer"); configValues.put("mp.initializer.allow", "true"); configValues.put("javax.sql.DataSource.test.dataSourceClassName", "org.mariadb.jdbc.MariaDbDataSource"); configValues.put("javax.sql.DataSource.test.dataSource.url", db.getJdbcUrl()); configValues.put("javax.sql.DataSource.test.dataSource.user", db.getUsername()); configValues.put("javax.sql.DataSource.test.dataSource.password", db.getPassword()); org.eclipse.microprofile.config.Config mpConfig = ConfigProviderResolver.instance() .getBuilder() .withSources(MpConfigSources.create(configValues)) .build(); ConfigProviderResolver.instance().registerConfig(mpConfig, Thread.currentThread().getContextClassLoader()); }
Как хорошо, что Девушки и Ребята из Test Container уже подготовили для нас MariaDB и Kafka контейнеры!
Все готово! Если мы запустим этот тест (даже непосредственно из IDE), он сначала запустит тестовые контейнеры, а после их инициализации и запуска, параметры будут настроены, и Helidon запустится. После этого все тесты выполняются с использованием этих тестовых контейнеров. Это означает, что все запросы к базе данных будут выполняться к MariaDB, а все сообщения - проходить через Kafka. Идея просто великолепна!
Мета-конфигурация
Также стоит упомянуть новую фишку Helidon MP - мета-конфигурация. Вы можете настроить сам Config с помощью функции мета-конфигурации Helidon MP Config.
При использовании конфигурация MicroProfile использует источники конфигурации и флаги, настроенные в мета-файле конфигурации.
Мета-конфигурация позволяет настраивать источники конфигурации и другие параметры конфигурации, включая добавление обнаруженных источников и разных конветеров.
Если файл с именем mp-meta-config.yamlили mp-meta-config.propertiesнаходится в текущем каталоге или в класспасе, и в коде нет явной настройки конфигурации, конфигурация будет загружена из meta-configфайла. Расположение файла можно изменить с помощью системного свойства io.helidon.config.mp.meta-configили переменной среды.HELIDON_MP_META_CONFIG.
Заключение
Helidon обеспечивает полную поддержку всех промышленных и де факто стандартных технологий тестирования. Модульное и интеграционное тестирование - неотъемлемая часть любой разработки программного обеспечения. Хорошие тесты гарантируют лучшее качество ваших программ! Так что, обязательно пишите тесты!
Если вы хотите поиграть с кодом и тестами из этой статьи - он лежит здесь .
Спасибо за внимание :)
