Helidon, Testcontainers, Cucumber, Kafka и многое другое
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 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 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, когда все ApplicationScoped
bean-компоненты запущены и работают. Как только event об этом выстрелил, мы можем заполнить данные. Это чистый CDI. Этот код на 100% portable.
Теперь все это надо протестировать!
Unit testing
Это, наверное, самое простое тестирование, но без него никак!
Все, что требует работы с БД или любыми внешними службами, требующими инициализации или работающими внутри контейнера CDI, можно легко заменить моками. Воспользуемся mockito
для этого:
@ExtendWith(MockitoExtension.class)
public class SockShopResourceMockTest {
private List 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:
com.spotify
dockerfile-maven-plugin
1.4.13
${project.build.finalName}
${project.build.finalName}.jar
true
package
build
Теперь, когда у нас есть приложение, упакованное, как докер образ и обернутое как 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 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 обеспечивает полную поддержку всех промышленных и де факто стандартных технологий тестирования. Модульное и интеграционное тестирование — неотъемлемая часть любой разработки программного обеспечения. Хорошие тесты гарантируют лучшее качество ваших программ! Так что, обязательно пишите тесты!
Если вы хотите поиграть с кодом и тестами из этой статьи — он лежит здесь .
Спасибо за внимание:)