Тестирование с помощью Testcontainers: как поднять в контейнере тестовую базу

Библиотека Testcontainers позволяет поднять тестовую среду в Docker-контейнере на время тестов, что довольно удобно.

Мы будем тестировать Spring Boot приложение, разработанное на базе PostgreSQL, и поэтому будем поднимать контейнер с образом PostgreSQL (13 версии). Для работы с таким контейнером в библиотеке Testcontainers есть класс PostgreSQLContainer (подкласс GenericContainer). Для многих баз данных есть свои классы-контейнеры.

Подготовка: установим Docker

Поскольку библиотека Testcontainers под капотом поднимает Docker-контейнер, на машине, где происходит тестирование, должен быть установлен Docker. Например, для Windows 10 его можно скачать отсюда.

Maven-зависимости

Чтобы работать с библиотекой Testcontainers, в POM-файл Spring Boot приложения необходимо добавить зависимость:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Поскольку мы будем работать с PostgreSQL, также добавим зависимости:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>
<dependency>
   <groupId>org.testcontainers</groupId>
   <artifactId>postgresql</artifactId>
   <scope>test</scope>
</dependency>

<dependency>
   <groupId>org.postgresql</groupId>
   <artifactId>postgresql</artifactId>
   <scope>runtime</scope>
</dependency>

@Testcontainers для JUnit Jupiter

Мы также будем использовать JUnit Jupiter (а не JUnit4), поэтому тестовый класс необходимо аннотировать @Testcontainers:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@SpringBootTest
@Testcontainers
public class IntegrationSqlScriptTest {
//тесты
}
@SpringBootTest @Testcontainers public class IntegrationSqlScriptTest { //тесты }
@SpringBootTest
@Testcontainers
public class IntegrationSqlScriptTest {
   //тесты
}

Следующая аннотация — @Container — тоже подразумевает использование JUnit Jupiter (для JUnit4 вместо @Container нужно использовать @Rule и @ClassRule).

@Container

Внутри класса теста необходимо создать поле, аннотированное с помощью @Container — собственно контейнер, который будет запускаться до и тушиться после тестов. Причем если поле static, то контейнер будет запускаться перед первым тестом и отключаться после последнего. У нас так и будет.

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

Итак, внутри вышеприведенного тестового класса создадим поле:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@Container
public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("mydb")
.withUsername("myuser")
.withPassword("mypass")
@Container public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:13") .withDatabaseName("mydb") .withUsername("myuser") .withPassword("mypass")
@Container
public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:13")
        .withDatabaseName("mydb")
        .withUsername("myuser")
        .withPassword("mypass")

В вышеприведенном коде:

  1. Мы задали поднимать контейнер на основе образа postgres:13 — если образ еще не скачан на локальную машину, то при первом запуске теста будет скачан с Docker Hub.
  2. На основе этого образа будет запущен контейнер с базой данных, причем до первого теста. Для создаваемой базы мы задали имя, логин, пароль (url, по которому можно присоединиться, доступен с помощью метода  postgreSQLContainer.getJdbcUrl(), он нам пригодится ниже).
  3. После завершения последнего теста класса контейнер будет выключен и удален.

Модель данных и репозиторий

У нас в приложении один класс Animal:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@Entity
public class Animal {
@Id
@GeneratedValue(strategy= GenerationType.SEQUENCE)
private long id;
private String name;
...
}
@Entity public class Animal { @Id @GeneratedValue(strategy= GenerationType.SEQUENCE) private long id; private String name; ... }
@Entity
public class Animal {
    @Id
    @GeneratedValue(strategy= GenerationType.SEQUENCE)
    private long id;
    private String name;
...
}

И соответствующий репозиторий:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public interface AnimalRepository extends JpaRepository<Animal, Long> {
}
public interface AnimalRepository extends JpaRepository<Animal, Long> { }
public interface AnimalRepository extends JpaRepository<Animal, Long> {
}

Для простоты его методы и будем тестировать (хотя это и бессмысленно).

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

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.hibernate.ddl-auto=validate

Создание схемы и заполнение базы

Контейнер поднимается, база в нем присутствует.  Осталась одна проблема — как все же создать схему базы и заполнить базу данными. Для этого мы будем запускать скрипты (два варианта: SQL и Liquibase).

Чтобы использовать Datasource вновь создаваемой базы данных из запускаемого контейнера, нам надо установить свойства этого Datasource в application.properties.

То есть свойства  application.properties:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
spring.datasource.url=
spring.datasource.username=
spring.datasource.password=
spring.datasource.url= spring.datasource.username= spring.datasource.password=
spring.datasource.url=
spring.datasource.username=
spring.datasource.password=

на время тестов нужно динамически заменить свойствами базы из контейнера. И это можно сделать.

Замена application.properties в ApplicationContextInitializer

Для этого создадим класс-инициализатор (он будет выполнять замену) и укажем его в аннотации @ContextConfiguration тестового класса.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@ContextConfiguration(initializers = {IntegrationSqlScriptTest.Initializer.class})
@ContextConfiguration(initializers = {IntegrationSqlScriptTest.Initializer.class})
@ContextConfiguration(initializers = {IntegrationSqlScriptTest.Initializer.class})

Итак, класс Initializer (он будет статический прямо внутри тестового класса, хотя это необязательно):

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@SpringBootTest
@ContextConfiguration(initializers = {IntegrationSqlScriptTest.Initializer.class})
@Testcontainers
public class IntegrationSqlScriptTest {
@Autowired
AnimalRepository animalRepository;
@Container
public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("mydb")
.withUsername("myuser")
.withPassword("mypass")
.withInitScript("db.sql");
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());
}
}
@Test
@Transactional
public void animalsCountShouldBeCorrect() {
long count = animalRepository.count();
assertEquals(3, count);
}
@Test
@Transactional
public void animalsShouldBeCorrect() {
List<Animal> animals = animalRepository.findAll();
String[] actualAnimals = {"animal1", "animal2", "animal3"};
assertArrayEquals(actualAnimals, animals.stream().map(animal -> animal.getName()).toArray());
}
}
@SpringBootTest @ContextConfiguration(initializers = {IntegrationSqlScriptTest.Initializer.class}) @Testcontainers public class IntegrationSqlScriptTest { @Autowired AnimalRepository animalRepository; @Container public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:13") .withDatabaseName("mydb") .withUsername("myuser") .withPassword("mypass") .withInitScript("db.sql"); 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()); } } @Test @Transactional public void animalsCountShouldBeCorrect() { long count = animalRepository.count(); assertEquals(3, count); } @Test @Transactional public void animalsShouldBeCorrect() { List<Animal> animals = animalRepository.findAll(); String[] actualAnimals = {"animal1", "animal2", "animal3"}; assertArrayEquals(actualAnimals, animals.stream().map(animal -> animal.getName()).toArray()); } }
@SpringBootTest
@ContextConfiguration(initializers = {IntegrationSqlScriptTest.Initializer.class})
@Testcontainers
public class IntegrationSqlScriptTest {

    @Autowired
    AnimalRepository animalRepository;

    @Container
    public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:13")
            .withDatabaseName("mydb")
            .withUsername("myuser")
            .withPassword("mypass")
            .withInitScript("db.sql");

    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());
        }
    }
    @Test
    @Transactional
    public void animalsCountShouldBeCorrect() {
        long count = animalRepository.count();
        assertEquals(3, count);

    }

    @Test
    @Transactional
    public void animalsShouldBeCorrect() {
        List<Animal> animals = animalRepository.findAll();
        String[] actualAnimals = {"animal1", "animal2", "animal3"};
        assertArrayEquals(actualAnimals, animals.stream().map(animal -> animal.getName()).toArray());
    }
}

Выше приведен весь код: добавлен AnimalRepository, и два очень простых теста.

При запуске тестов application.properties заменяются свойствами базы из контейнера.

Создание схемы и заполнение базы обычным скриптом

И обратите внимание, что для контейнера PostgreSQLContainer в коде выше задан скрипт с помощью метода

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
.withInitScript("db.sql");
.withInitScript("db.sql");
.withInitScript("db.sql");

Скрипт db.sql лежит в папке /resources. В нем создается таблица, таблица заполняется данными и создается последовательность. (Последовательность hibernate_sequence нужна, поскольку стратегия генерации идентификатора в сущности Animal выбрана @GeneratedValue(strategy= GenerationType.SEQUENCE)).

Содержимое db.sql:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
CREATE SEQUENCE hibernate_sequence START 100;
create table animal
(
id bigint primary key,
name varchar(255)
);
insert into animal (id, name)
values (1,'animal1'),
(2,'animal2'),
(3,'animal3');
CREATE SEQUENCE hibernate_sequence START 100; create table animal ( id bigint primary key, name varchar(255) ); insert into animal (id, name) values (1,'animal1'), (2,'animal2'), (3,'animal3');
CREATE SEQUENCE hibernate_sequence START 100;
create table animal
(
    id       bigint primary key,
    name     varchar(255)
);

insert into animal (id, name)
values (1,'animal1'),
       (2,'animal2'),
       (3,'animal3');

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

Создание схемы и заполнение базы с помощью Liquibase

Схему и данные можно создать и с помощью Liquibase-скриптов. В этом случае скрипт db.sql больше не нужен, и строку  .withInitScript(«db.sql»); следует убрать.

Вместо этого нам надо заменить еще одно свойство —  включить spring.liquibase.enabled=true:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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(),
"spring.liquibase.enabled=true"
).applyTo(configurableApplicationContext.getEnvironment());
}
}
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(), "spring.liquibase.enabled=true" ).applyTo(configurableApplicationContext.getEnvironment()); } }
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(),
                "spring.liquibase.enabled=true"
        ).applyTo(configurableApplicationContext.getEnvironment());
    }
}

По умолчанию оно задано равным false, чтобы Liquibase-скрипты не запускались при выполнении тестов предыдущего тестового класса с db.sql.

В остальном новый тестовый класс остается таким же, как предыдущий.

Конечно, Liquibase скрипты должны лежать в ресурсах:

Расположение Liquibase-скриптов
Расположение Liquibase-скриптов

Подробнее о Liquibase есть статья, и там и здесь используется путь по умолчанию db/changelog/db.changelog-master.yaml до скриптов.

Итоги

Мы рассмотрели библиотеку Testcontainers и использовали ее (совместно с JUnit Jupiter) для поднятия контейнера с базой PostgreSQL 13.

Мы рассмотрели, как заменить при запуске теста application.properties с помощью ApplicationContextInitializer.

Схему и данные в базе мы создали двумя способами — обычным sql-скриптом и с помощью Liquibase.

Код примера с обоими тестами доступен на GitHub.

Тестирование с помощью Testcontainers: как поднять в контейнере тестовую базу: 2 комментария

  1. Отличный сайт! Вопрос по статье. Есть ли возможность поднять контейнер описанный в docker-compose?

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *