Интеграционные тесты

Интеграционный тест предназначен для охвата более широкой области, чем юнит-тест. Он позволяет выполнять код в окружении, близком к нормальному времени выполнения приложения. Когда речь идет об интеграционных тестах, мы имеем в виду тесты, которые запускают полный контекст Spring и взаимодействуют с базой данных, если это необходимо.

Используйте действие New → Advanced → Integration Test в окне инструментов Jmix, чтобы быстро создать интеграционный тест с помощью Studio.

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

OrderLineEventListener.java
@EventListener
public void recalculateOrderAmount(EntityChangedEvent<OrderLine> event) {
    Order order = findOrderFromEvent(event);

    BigDecimal amount = new OrderAmountCalculation().calculateTotalAmount(order.getLines());
    order.setAmount(amount);

    dataManager.save(order);
}

В интеграционном тесте OrderLineEventListener и OrderAmountCalculation могут быть протестированы вместе. Тест создаст заказ и строку заказа и сохранит их в базу данных с использованием API DataManager. Это вызовет срабатывание слушателя событий, и сумма заказа будет рассчитана.

Инжекция зависимостей в тестах

Интеграционный тест Spring может использовать тот же механизм инжекции зависимостей, что и код приложения. В частности, можно использовать аннотацию @Autowired для инжекции бинов в класс теста. В примере ниже DataManager инжектируется в класс теста для того, чтобы через него вызвать логику OrderLineEventListener:

OrderLineEventListenerTest.java
@SpringBootTest
public class OrderLineEventListenerTest {

    @Autowired
    DataManager dataManager;

    // ...
}

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

CustomerServiceTest.java
@SpringBootTest
public class CustomerServiceTest {

    @Autowired
    CustomerService customerService;

    // ...
}

Взаимодействие с базой данных

Существует две основные причины взаимодействия с базой данных в интеграционном тесте.

Первая - это возможность настройки тестовых данных, необходимых для выполнения тестового случая. Для взаимодействия с базой данных можно использовать обычную для продакшн-кода функциональность Jmix, такую как DataManager.

Вторая причина заключается в том, что к базе данных может обращаться логика приложения, выполняемая в тесте.

Давайте рассмотрим пример для обоих этих сценариев:

CustomerServiceTest.java
@Test
void given_customerWithEmailExists_when_findByEmail_then_customerFound() {

    // given
    Customer customer = dataManager.create(Customer.class);
    customer.setEmail("customer@test.com");
    dataManager.save(customer); (1)

    // when
    Optional<Customer> foundCustomer = customerService.findByEmail("customer@test.com");  (2)

    // then
    assertThat(foundCustomer)
            .isPresent();
}
1 DataManager используется в тесте для создания тестового клиента в базе данных.
2 CustomerService используется для выполнения поиска клиентов по электронной почте.

Очистка тестовых данных

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

Существует несколько способов очистки тестовых данных. Первый - это сохранение ссылок на сущности, созданные во время теста. В приведенном выше примере вы можете сохранить ссылку на созданного в тесте клиента и удалить его после завершения теста с использованием dataManager.remove(customer). Это рабочий подход, но он требует дополнительного кода в тесте. Кроме того, не всегда возможно сохранить ссылку на данные, созданные во время теста. Например, если новая сущность создается в логике приложения, вы скорее всего не сможете получить на нее ссылку в тесте. Кроме того, в случае исключения во время теста код очистки может не выполниться.

Второй вариант - проведение более общей очистки базы данных. В следующем примере JdbcTemplate выполняет операцию SQL DELETE FROM CUSTOMER, чтобы удалить всех клиентов из базы данных:

CustomerServiceTest.java
@Autowired (1)
DataSource dataSource;

@AfterEach (2)
void tearDown() {
    JdbcTemplate jdbc = new JdbcTemplate(dataSource);
    JdbcTestUtils.deleteFromTables(jdbc, "CUSTOMER"); (3)
}
1 DataSource инжектируется для создания экземпляра JdbcTemplate.
2 @AfterEach указывает JUnit, что данный метод должен быть выполнен после каждого тестового случая.
3 Класс Spring JdbcTestUtils предоставляет удобный метод для удаления всех данных из таблицы базы данных. См. дополнительную информацию в документации Spring testing.

Контекст безопасности в тестах

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

Давайте рассмотрим пример тестирования метода CustomerService, который ведет себя по-разному в зависимости от роли пользователя, выполняющего его:

CustomerService.java
@Component
public class CustomerService {

    @Autowired
    private DataManager dataManager;

    public Optional<Customer> findByEmail(String email) {
        return dataManager.load(Customer.class)
                .query("select c from sample_Customer c where c.email = :email")
                .parameter("email", email)
                .optional();
    }
}

В этом примере CustomerService имеет метод findCustomerByEmail, который возвращает сущность клиента, если она найдена. Политики подсистемы безопасности разрешают доступ к данным клиента только для определенных ролей. Это поведение можно протестировать, используя SystemAuthenticator для выполнения метода от имени конкретного пользователя:

CustomerServiceTest.java
private final String USERNAME = "userWithoutPermissions";

@Test
void given_noPermissionsToReadCustomerData_when_findByEmail_then_nothingFound() {

    // given
    Customer customer = dataManager.create(Customer.class);
    customer.setEmail("customer@test.com");
    dataManager.save(customer);

    // and
    User userWithoutPermissions = dataManager.create(User.class);
    userWithoutPermissions.setUsername(USERNAME);
    dataManager.save(userWithoutPermissions); (1)

    // when
    Optional<Customer> foundCustomer = systemAuthenticator.withUser( (2)
            USERNAME,
            () -> customerService.findByEmail("customer@test.com") (3)
    );

    // then
    assertThat(foundCustomer)
            .isNotPresent();
}
1 Для теста создается новый пользователь без присвоения каких-либо ролей.
2 SystemAuthenticator выполняет тестируемый код от имени только что созданного пользователя.
3 CustomerService выполняет поиск клиентов по электронной почте с контекстом безопасности этого пользователя.

Так как у пользователя нет ролей, сервис возвращает пустой Optional.

AuthenticatedAsAdmin

Вместо того, чтобы устанавливать контекст безопасности в определенных местах теста, можно использовать расширение JUnit AuthenticatedAsAdmin, которое автоматически создается в новом проекте Jmix. Оно создает контекст безопасности перед каждым тестом и устанавливает аутентифицированного пользователя в администратора.

CustomerServiceTest.java
@SpringBootTest
@ExtendWith(AuthenticatedAsAdmin.class)
public class CustomerServiceTest {
    // ...

Также можно объединить расширение AuthenticatedAsAdmin с SystemAuthenticator для выполнения кода теста от имени конкретного пользователя. Аннотировав класс теста, контекст безопасности по умолчанию устанавливается в администратора. Но внутри тест-кейса можно использовать SystemAuthenticator для выполнения кода от имени конкретного пользователя.

Изменение поведения приложения

Иногда, даже для интеграционных тестов, необходимо подменить определенные части приложения. В этих случаях можно комбинировать функциональность @SpringBootTest с Mockito и подменять определенные бины, но при этом использовать общий контекст Spring.

Давайте рассмотрим NotificationService, который в рамках своей бизнес-логики использует API Emailer из дополнения Email Sending. Интеграционный тест для этого сервиса не должен фактически отправлять письма, поэтому функциональность электронной почты должна быть подменена.

@MockBean

Для создания фиктивного объекта бина в интеграционном тесте Spring можно использовать аннотацию @MockBean. В следующем примере бин Emailer подменяется моком для теста NotificationService, описанного выше:

package com.company.demo.app;

import com.company.demo.entity.Customer;
import io.jmix.email.EmailException;
import io.jmix.email.EmailInfo;
import io.jmix.email.Emailer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;

@SpringBootTest
class NotificationServiceTest {

    @MockBean
    Emailer emailer;

    @Autowired
    NotificationService notificationService;

    @Test
    void given_emailDelivered_when_sendNotification_then_success() throws EmailException {

        // given:
        Customer customer = new Customer();
        customer.setEmail("customer@company.com");

        // when:
        boolean success = notificationService.notifyCustomerAboutOrderDelivered(customer);

        // then:
        assertThat(success).isTrue();
    }


    @Test
    void given_emailNotDelivered_when_sendNotification_then_noSuccess() throws EmailException {

        // given:
        doThrow(EmailException.class).when(emailer)
                .sendEmail(any(EmailInfo.class));

        // and:
        Customer customer = new Customer();
        customer.setEmail("customer@company.com");

        // when:
        boolean success = notificationService.notifyCustomerAboutOrderDelivered(customer);

        // then:
        assertThat(success).isFalse();
    }
}

Аннотация @MockBean заменяет бин в контексте приложения на фиктивный объект. Это позволяет достичь следующего:

  1. Избежать фактической отправки электронной почты.

  2. Симулировать сценарий отказа, когда отправка электронной почты не удалась.

@TestConfiguration

В приведенном выше примере аннотация @MockBean используется для замены бина Emailer фиктивным объектом. Но это не единственный способ заменить бин в контексте приложения. Другой способ - использовать аннотацию @TestConfiguration. Эта аннотация задается на классе конфигурации, который используется только для теста. В следующем примере класс тестовой конфигурации заменяет бин Emailer на фиктивный объект:

package com.company.demo.app;

import com.company.demo.entity.Customer;
import io.jmix.email.EmailException;
import io.jmix.email.EmailInfo;
import io.jmix.email.Emailer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;


@SpringBootTest
class NotificationServiceWithTestConfigurationTest {

    @Autowired
    NotificationService notificationService;

    @TestConfiguration (1)
    public static class EmailerTestConfiguration {
        @Bean
        public Emailer emailer() throws EmailException { (2)
            Emailer emailer = mock(Emailer.class); (3)

            doThrow(EmailException.class).when(emailer) (4)
                    .sendEmail(any(EmailInfo.class));

            return emailer;
        }
    }

    @Test
    void given_emailNotDelivered_when_sendNotification_then_noSuccess() throws EmailException {

        // given:
        Customer customer = new Customer();
        customer.setEmail("customer@company.com");

        // when:
        boolean success = notificationService.notifyCustomerAboutOrderDelivered(customer);

        // then:
        assertThat(success).isFalse();
    }

}
1 Внутренний статический класс, аннотированный @TestConfiguration, будет использован Spring при выполнении тестового случая.
2 Объявлен бин с именем emailer типа Emailer. Он переопределяет стандартный бин этого типа.
3 Создается фиктивный экземпляр (мок).
4 Указывается поведение мока, и настроенный мок возвращается.

Продакшн-код, взаимодействующий с бином Emailer, теперь будет использовать фиктивный объект вместо стандартной реализации.