Интеграционные тесты
Интеграционный тест предназначен для охвата более широкой области, чем юнит-тест. Он позволяет выполнять код в окружении, близком к нормальному времени выполнения приложения. Когда речь идет об интеграционных тестах, мы имеем в виду тесты, которые запускают полный контекст Spring и взаимодействуют с базой данных, если это необходимо.
В примере ниже мы снова будем тестировать класс OrderAmountCalculation
, но на этот раз не как изолированный блок (как описано в предыдущем разделе), а в более широком контексте, в котором он используется в приложении. В данном случае существует слушатель событий EntityChangedEvent
сущностей OrderLine
. В рамках логики сохранения, слушатель пересчитывает сумму заказа, которому принадлежит строка заказа, с помощью класса OrderAmountCalculation
:
@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
:
@SpringBootTest
public class OrderLineEventListenerTest {
@Autowired
DataManager dataManager;
// ...
}
Если вам нужно протестировать собственный бин напрямую, вы также можете инжектировать этот бин в класс теста. В следующем примере CustomerService
инжектируется в класс теста для непосредственного вызова его тестируемых методов:
@SpringBootTest
public class CustomerServiceTest {
@Autowired
CustomerService customerService;
// ...
}
Взаимодействие с базой данных
Существует две основные причины взаимодействия с базой данных в интеграционном тесте.
Первая - это возможность настройки тестовых данных, необходимых для выполнения тестового случая. Для взаимодействия с базой данных можно использовать обычную для продакшн-кода функциональность Jmix, такую как DataManager
.
Вторая причина заключается в том, что к базе данных может обращаться логика приложения, выполняемая в тесте.
Давайте рассмотрим пример для обоих этих сценариев:
@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
, чтобы удалить всех клиентов из базы данных:
@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
, который ведет себя по-разному в зависимости от роли пользователя, выполняющего его:
@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
для выполнения метода от имени конкретного пользователя:
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. Оно создает контекст безопасности перед каждым тестом и устанавливает аутентифицированного пользователя в администратора.
@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
заменяет бин в контексте приложения на фиктивный объект. Это позволяет достичь следующего:
-
Избежать фактической отправки электронной почты.
-
Симулировать сценарий отказа, когда отправка электронной почты не удалась.
@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
, теперь будет использовать фиктивный объект вместо стандартной реализации.