Перейти к основному содержимому

Интеграционные тесты БД с Spring Boot и тестовыми контейнерами

· 6 мин. чтения

1. Обзор

Spring Data JPA предоставляет простой способ создавать запросы к базе данных и тестировать их с помощью встроенной базы данных H2.

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

В этом руководстве мы покажем, как использовать Testcontainers для интеграционного тестирования с Spring Data JPA и базой данных PostgreSQL.

В нашем предыдущем уроке мы создали несколько запросов к базе данных, используя в основном аннотацию @Query , которую мы сейчас протестируем.

2. Конфигурация

Чтобы использовать базу данных PostgreSQL в наших тестах, мы должны добавить зависимость Testcontainers с областью тестирования и драйвером PostgreSQL в наш pom.xml :

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.10.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.2.5</version>
</dependency>

Давайте также создадим файл application.properties в каталоге тестовых ресурсов, в котором мы проинструктируем Spring использовать правильный класс драйвера, а также создавать и удалять схему при каждом запуске теста:

spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=create-drop

3. Использование одного теста

Чтобы начать использовать экземпляр PostgreSQL в одном тестовом классе, мы должны сначала создать определение контейнера, а затем использовать его параметры для установления соединения:

@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(initializers = {UserRepositoryTCIntegrationTest.Initializer.class})
public class UserRepositoryTCIntegrationTest extends UserRepositoryCommonIntegrationTests {

@ClassRule
public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:11.1")
.withDatabaseName("integration-tests-db")
.withUsername("sa")
.withPassword("sa");

static class Initializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
TestPropertyValues.of(
"spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(),
"spring.datasource.username=" + postgreSQLContainer.getUsername(),
"spring.datasource.password=" + postgreSQLContainer.getPassword()
).applyTo(configurableApplicationContext.getEnvironment());
}
}
}

В приведенном выше примере мы использовали @ClassRule из JUnit для настройки контейнера базы данных перед выполнением тестовых методов . Мы также создали статический внутренний класс, реализующий ApplicationContextInitializer. В качестве последнего шага мы применили аннотацию @ContextConfiguration к нашему тестовому классу с классом инициализатора в качестве параметра.

Выполняя эти три действия, мы можем установить свойства подключения до публикации контекста Spring.

Давайте теперь воспользуемся двумя запросами UPDATE из предыдущей статьи:

@Modifying
@Query("update User u set u.status = :status where u.name = :name")
int updateUserSetStatusForName(@Param("status") Integer status,
@Param("name") String name);

@Modifying
@Query(value = "UPDATE Users u SET u.status = ? WHERE u.name = ?",
nativeQuery = true)
int updateUserSetStatusForNameNative(Integer status, String name);

И протестируйте их с настроенной средой:

@Test
@Transactional
public void givenUsersInDB_WhenUpdateStatusForNameModifyingQueryAnnotationJPQL_ThenModifyMatchingUsers(){
insertUsers();
int updatedUsersSize = userRepository.updateUserSetStatusForName(0, "SAMPLE");
assertThat(updatedUsersSize).isEqualTo(2);
}

@Test
@Transactional
public void givenUsersInDB_WhenUpdateStatusForNameModifyingQueryAnnotationNative_ThenModifyMatchingUsers(){
insertUsers();
int updatedUsersSize = userRepository.updateUserSetStatusForNameNative(0, "SAMPLE");
assertThat(updatedUsersSize).isEqualTo(2);
}

private void insertUsers() {
userRepository.save(new User("SAMPLE", "email@example.com", 1));
userRepository.save(new User("SAMPLE1", "email2@example.com", 1));
userRepository.save(new User("SAMPLE", "email3@example.com", 1));
userRepository.save(new User("SAMPLE3", "email4@example.com", 1));
userRepository.flush();
}

В приведенном выше сценарии первый тест завершается успешно, но второй выдает InvalidDataAccessResourceUsageException с сообщением:

Caused by: org.postgresql.util.PSQLException: ERROR: column "u" of relation "users" does not exist

Если бы мы запустили те же тесты, используя встроенную базу данных H2, оба теста завершились бы успешно, но PostgreSQL не принимает псевдонимы в предложении SET. Мы можем быстро исправить запрос, удалив проблемный псевдоним:

@Modifying
@Query(value = "UPDATE Users u SET status = ? WHERE u.name = ?",
nativeQuery = true)
int updateUserSetStatusForNameNative(Integer status, String name);

На этот раз оба теста завершаются успешно. В этом примере мы использовали Testcontainers для выявления проблемы с собственным запросом, которая в противном случае была бы обнаружена после переключения на реальную базу данных в рабочей среде. Мы также должны отметить, что использование запросов JPQL в целом безопаснее, поскольку Spring правильно переводит их в зависимости от используемого поставщика базы данных.

3.1. Одна база данных на тест с конфигурацией

До сих пор мы использовали правила JUnit 4 для запуска экземпляра базы данных перед запуском всех тестов внутри тестового класса. В конце концов, этот подход создаст экземпляр базы данных перед каждым тестовым классом и удалит его после выполнения всех тестов в каждом классе.

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

В дополнение к подходу правил JUnit 4 мы можем изменить URL-адрес JDBC и указать Testcontainers создать экземпляр базы данных для каждого тестового класса . Этот подход будет работать, не требуя от нас написания инфраструктурного кода в наших тестах.

Например, чтобы переписать приведенный выше пример, все, что нам нужно сделать, это добавить это в наш application.properties :

spring.datasource.url=jdbc:tc:postgresql:11.1:///integration-tests-db

« tc:» заставит Testcontainers создавать экземпляры базы данных без каких-либо изменений кода. Итак, наш тестовый класс будет таким же простым, как:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class UserRepositoryTCJdbcLiveTest extends UserRepositoryCommon {

@Test
@Transactional
public void givenUsersInDB_WhenUpdateStatusForNameModifyingQueryAnnotationNative_ThenModifyMatchingUsers() {
// same as above
}
}

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

4. Общий экземпляр базы данных

В предыдущем абзаце мы описали, как использовать Testcontainers в одном тесте. В реальном сценарии мы хотели бы повторно использовать один и тот же контейнер базы данных в нескольких тестах из-за относительно длительного времени запуска.

Давайте теперь создадим общий класс для создания контейнера базы данных, расширив PostgreSQLContainer и переопределив методы start() и stop() :

public class ForEachPostgresqlContainer extends PostgreSQLContainer<ForEachPostgresqlContainer> {
private static final String IMAGE_VERSION = "postgres:11.1";
private static ForEachPostgresqlContainer container;

private ForEachPostgresqlContainer() {
super(IMAGE_VERSION);
}

public static ForEachPostgresqlContainer getInstance() {
if (container == null) {
container = new ForEachPostgresqlContainer();
}
return container;
}

@Override
public void start() {
super.start();
System.setProperty("DB_URL", container.getJdbcUrl());
System.setProperty("DB_USERNAME", container.getUsername());
System.setProperty("DB_PASSWORD", container.getPassword());
}

@Override
public void stop() {
//do nothing, JVM handles shut down
}
}

Оставив метод stop() пустым, мы позволяем JVM обрабатывать отключение контейнера. Мы также реализуем простой одноэлементный шаблон, в котором только первый тест запускает контейнер, а каждый последующий тест использует существующий экземпляр. В методе start() мы используем System#setProperty для установки параметров соединения в качестве переменных среды.

Теперь мы можем поместить их в наш файл application.properties :

spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}

Давайте теперь используем наш служебный класс в определении теста:

@RunWith(SpringRunner.class)
@SpringBootTest
public class UserRepositoryTCAutoIntegrationTest {

@ClassRule
public static PostgreSQLContainer postgreSQLContainer = ForEachPostgresqlContainer.getInstance();

// tests
}

Как и в предыдущих примерах, мы применили аннотацию @ClassRule к полю, содержащему определение контейнера. Таким образом, свойства соединения DataSource заполняются правильными значениями до создания контекста Spring.

Теперь мы можем реализовать несколько тестов с использованием одного и того же экземпляра базы данных, просто определив аннотированное поле @ClassRule , созданное с помощью нашего служебного класса ForEachPostgresqlContainer .

5. Вывод

В этой статье мы проиллюстрировали способы выполнения тестов на реальном экземпляре базы данных с помощью Testcontainers.

Мы рассмотрели примеры использования одного теста с использованием механизма ApplicationContextInitializer из Spring, а также реализации класса для повторного использования экземпляров базы данных.

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

Как всегда, полный код, использованный в этой статье, доступен на GitHub .