Helidon, Testcontainers, Cucumber, Kafka и многое другое +5



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}"));
}

Когда пользователь запускает этот сценарий, происходит следующее:

  1. Testcontainers берет самый свежий образ socks-shopприложения и запускает его;

  2. Socks-shop загружается и инициализацилируется внутри контейнера;

  3. Когда приложение заработает, вызывается `a_user_makes_a_checkout ()`;

  4. Если все отработало штатно, the_checkout_is_performed() выполнится для проверки того, что заказ персистится правильно.

  5. И, наконец, 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 обеспечивает полную поддержку всех промышленных и де факто стандартных технологий тестирования. Модульное и интеграционное тестирование - неотъемлемая часть любой разработки программного обеспечения. Хорошие тесты гарантируют лучшее качество ваших программ! Так что, обязательно пишите тесты!

Если вы хотите поиграть с кодом и тестами из этой статьи - он лежит здесь .

Спасибо за внимание :)




Комментарии (6):