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

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

По сути, приложение представляет собой сервис, который предоставляет несколько конечных точек GraphQL для создания, запроса и удаления рецензий из базы данных PostgreSQL через Spring Data R2DBC. Приложение написано на Kotlin с использованием Spring Boot 2.7.3. 

Я решил написать эту статью специально для Spring Data R2DBC, поскольку в случае Spring Data JPA интеграционное тестирование с Testcontainers не вызывает затруднений. Тем не менее, когда дело доходит до R2DBC, возникают некоторые проблемы, которые необходимо решить.

Обзор приложения

Итак, давайте перейдем к делу. Давайте рассмотрим наш домен.

@Table("reviews")
data class Review(
    @Id
    var id: Int? = null,
    var text: String,
    var author: String,
    @Column("created_at")
    @CreatedDate
    var createdAt: LocalDateTime? = null,
    @LastModifiedDate
    @Column("last_modified_at")
    var lastModifiedAt: LocalDateTime? = null,
    @Column("course_id")
    var courseId: Int
)

А вот его репозиторий:

@Repository
interface ReviewRepository : R2dbcRepository<Review, Int> {
    @Query("select * from reviews r where date(r.created_at) = :date")
    fun findAllByCreatedAt(date: LocalDate): Flux<Review>
    fun findAllByAuthor(author: String): Flux<Review>
    fun findAllByCreatedAtBetween(startDateTime: LocalDateTime, endDateTime: LocalDateTime): Flux<Review>
}

А вот и свойства подключения:

spring:
  data:
    r2dbc:
      repositories:
        enabled: true
  r2dbc:
    url: r2dbc:postgresql://localhost:5436/reviews-db
    username: postgres
    password: 123456

Когда дело доходит до тестирования, Spring предлагает довольно простой способ создания для этой цели базы данных в памяти H2, но... всегда есть но. H2 имеет некоторые недостатки:

  • Во-первых, обычно H2 не является производственной БД; в нашем случае мы используем PostgreSQL, и сложно поддерживать две схемы БД, одну для производственного использования, а другую для интеграционного тестирования, особенно если вы зависите от некоторых возможностей провайдера (запросов, функций, ограничений и так далее). Чтобы быть максимально уверенным в тестах, всегда рекомендуется максимально воспроизводить производственную среду, чего нельзя сказать о H2.

  • Еще одним недостатком может быть возможность миграции производственной БД на другую базу данных - в этом случае H2, имея другую схему, не поймает никаких проблем, следовательно, будет ненадежен.

Некоторые могут возразить, что для тестирования можно иметь отдельную выделенную БД на сервере, машине или даже в другой схеме. Тем не менее опять же, проблемы с поддержкой схем и возможность конфликтов с изменениями/миграциями других разработчиков слишком велики.

Testcontainers спешат на помощь

Testcontainers  — это библиотека Java, которая поддерживает тесты JUnit, предоставляя легковесные одноразовые экземпляры обычных баз данных, Веб-браузеров Selenium или чего-либо еще, что может работать в контейнере Docker.

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

Вот зависимости, которые мы будем использовать:

testImplementation("org.testcontainers:testcontainers:1.17.3")
testImplementation("org.testcontainers:postgresql:1.17.3")

Существуют разные способы работы с Testcontainers в Spring Boot. Тем не менее, я покажу вам шаблон singleton-instance (одна база данных для всех тестов), поскольку гораздо быстрее один раз запустить экземпляр базы данных и позволить всем вашим тестам взаимодействовать с ней. 

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

@Tag("integration-test")
abstract class AbstractTestcontainersIntegrationTest {

    companion object {

        private val postgres: PostgreSQLContainer<*> = PostgreSQLContainer(DockerImageName.parse("postgres:13.3"))
            .apply {
                this.withDatabaseName("testDb").withUsername("root").withPassword("123456")
            }

        @JvmStatic
        @DynamicPropertySource
        fun properties(registry: DynamicPropertyRegistry) {
            registry.add("spring.r2dbc.url", Companion::r2dbcUrl)
            registry.add("spring.r2dbc.username", postgres::getUsername)
            registry.add("spring.r2dbc.password", postgres::getPassword)
        }

        fun r2dbcUrl(): String {
            return "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)}/${postgres.databaseName}"
        }

        @JvmStatic
        @BeforeAll
        internal fun setUp(): Unit {
            postgres.start()
        }
    }

}

Давайте внимательно посмотрим, что мы имеем. Здесь мы используем способность testcontainers извлекать образы Docker и, следовательно, создавать экземпляр контейнера базы данных.

private val postgres: PostgreSQLContainer<*> = PostgreSQLContainer(DockerImageName.parse("postgres:13.3"))

Здесь мы переопределим свойства подключения R2DBC во время выполнения на те, которые указывают на наш вновь созданный контейнер.

 @JvmStatic
 @DynamicPropertySource
 fun properties(registry: DynamicPropertyRegistry) {
     registry.add("spring.r2dbc.url", Companion::r2dbcUrl)
     registry.add("spring.r2dbc.username", postgres::getUsername)
     registry.add("spring.r2dbc.password", postgres::getPassword)
 }

Поскольку мы используем Spring Data R2DBC, spring.r2dbc.url требует немного больше внимания, чтобы быть правильно построенным, так как на данный момент PostgreSQLContainer предоставляет только метод getJdbcUrl().

fun r2dbcUrl(): String {
     return "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)}/${postgres.databaseName}"
 }

И здесь мы запускаем наш контейнер перед всеми тестами.

@JvmStatic
@BeforeAll
internal fun setUp(): Unit {
    postgres.start()
}

Имея все это, мы готовы написать несколько тестов.

@DataR2dbcTest
@Tag("integration")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ReviewRepositoryIntegrationTest : AbstractTestcontainersIntegrationTest() {

    @Autowired
    lateinit var reviewRepository: ReviewRepository

    @Test
    fun findAllByAuthor() {
        StepVerifier.create(reviewRepository.findAllByAuthor("Anonymous"))
            .expectNextCount(3)
            .verifyComplete()
    }

    @Test
    fun findAllByCreatedAt() {
        StepVerifier.create(reviewRepository.findAllByCreatedAt(LocalDate.parse("2022-11-14")))
            .expectNextCount(1)
            .verifyComplete()
    }


    @Test
    fun findAllByCreatedAtBetween() {
        StepVerifier.create(
            reviewRepository.findAllByCreatedAtBetween(
                LocalDateTime.parse("2022-11-14T00:08:54.266024"),
                LocalDateTime.parse("2022-11-17T00:08:56.902252")
            )
        )
            .expectNextCount(4)
            .verifyComplete()
    }
}

Что мы имеем здесь:

  • @DataR2dbcTest — это аннотация к фрагменту запуска Spring Boot теста, которая может быть использована для теста R2DBC, сфокусированного только на компонентах Data R2DBC. 

  • @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) — здесь мы говорим Spring не беспокоиться о настройке тестовой базы данных, поскольку мы собираемся сделать это сами.

  • И поскольку у нас есть R2dbcRepository, который предоставляет данные реактивным способом с помощью FluxMono, мы используем StepVerifier для создания проверяемого сценария для наших асинхронных последовательностей Publisher, выражая ожидания относительно событий, которые произойдут при подписке.

Круто, правда? Давайте запустим это.

io.r2dbc.postgresql.ExceptionFactory$PostgresqlBadGrammarException: [42P01] relation "reviews" does not exist

Расширения в действии

Облом! Мы забыли позаботиться о нашей схеме и данных/записях. Но как же нам это сделать? В Spring Data JPA об этом можно позаботиться, используя следующее:

spring.sql.init.mode=always # Spring Boot >=v2.5.0
spring.datasource.initialization-mode=always # Spring Boot <v2.5.0

А если поместить schema.sql в папку с DDL и data.sql с DML в папку src/main/resources, то все работает автоматически. Или, если у вас используется Flyway/Liquibase, есть и другие методы сделать это. Более того, в Spring Data JPA есть @Sql, который позволяет запускать различные .sql файлы перед тестовым методом, чего было бы достаточно для нашего случая.

Но это не тот случай, мы используем Spring Data R2DBC, который на данный момент не поддерживает подобные функции, и у нас нет фреймворка миграции. 

Таким образом, на наши плечи ложится ответственность за написание чего-то похожего на то, что предлагает Spring Data JPA, что будет достаточным и настраиваемым для легкого написания интеграционных тестов. Попробуем повторить аннотацию @Sql, создав аналогичную аннотацию @RunSql.

@Target(AnnotationTarget.FUNCTION)
annotation class RunSql(val scripts: Array<String>)

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

BeforeTestExecutionCallback определяет API для расширений, которые должны обеспечить дополнительные действия для тестов непосредственно перед выполнением отдельного теста, но после выполнения любых пользовательских методов настройки (например, методов @BeforeEach) для этого теста.

Это звучит правильно: давайте расширим его и переопределим метод beforeTestExecution.

class RunSqlExtension : BeforeTestExecutionCallback {
    override fun beforeTestExecution(extensionContext: ExtensionContext?) {
        val annotation = extensionContext?.testMethod?.map { it.getAnnotation(RunSql::class.java) }?.orElse(null)
        annotation?.let {
            val testInstance = extensionContext.testInstance
                .orElseThrow { RuntimeException("Test instance not found. ${javaClass.simpleName} is supposed to be used in junit 5 only!") }
            val connectionFactory = getConnectionFactory(testInstance)
            if (connectionFactory != null)
                it.scripts.forEach { script ->
                    Mono.from(connectionFactory.create())
                        .flatMap<Any> { connection -> ScriptUtils.executeSqlScript(connection, ClassPathResource(script)) }.block()
                }
        }
    }

    private fun getConnectionFactory(testInstance: Any?): ConnectionFactory? {
        testInstance?.let {
            return it.javaClass.superclass.declaredFields
                .find { it.name.equals("connectionFactory") }
                .also { it?.isAccessible = true }?.get(it) as ConnectionFactory
        }
        return null
    }
}

Итак, что мы здесь сделали: 

  1. Мы берем текущий метод теста из контекста расширения и проверяем аннотацию @RunSq.

  2. Мы берем текущий экземпляр теста — это экземпляр нашего запущенного теста.

  3. Мы передаем текущий экземпляр теста в getConnectionFactory, чтобы он мог получить @Autowired ConnectionFactory из нашего родителя, в нашем случае, абстрактного класса AbstractTestcontainersIntegrationTest. 

  4. Используя полученные connectionFactoryScriptUtils,мы выполняем скрипты, найденные в аннотации @RunSql, в блокирующем режиме.

Как я уже говорил, наш AbstractTestcontainersIntegrationTest нуждается в небольшом изменении: нам нужно добавить код @Autowired lateinit var connectionFactory: ConnectionFactory, чтобы он мог быть выбран нашим расширением. 

Когда все готово, остается только использовать это расширение с помощью @ExtendWith(RunSqlExtension::class) и нашей новой аннотацией @RunSql. Вот как теперь выглядит наш тест.

@DataR2dbcTest
@Tag("integration")
@ExtendWith(RunSqlExtension::class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ReviewRepositoryIntegrationTest : AbstractTestcontainersIntegrationTest() {

    @Autowired
    lateinit var reviewRepository: ReviewRepository

    @Test
    @RunSql(["schema.sql", "/data/reviews.sql"])
    fun findAllByAuthor() {
        StepVerifier.create(reviewRepository.findAllByAuthor("Anonymous"))
            .expectNextCount(3)
            .verifyComplete()
    }

    @Test
    @RunSql(["schema.sql", "/data/reviews.sql"])
    fun findAllByCreatedAt() {
        StepVerifier.create(reviewRepository.findAllByCreatedAt(LocalDate.parse("2022-11-14")))
            .expectNextCount(1)
            .verifyComplete()
    }

    @Test
    @RunSql(["schema.sql", "/data/reviews.sql"])
    fun findAllByCreatedAtBetween() {
        StepVerifier.create(
            reviewRepository.findAllByCreatedAtBetween(
                LocalDateTime.parse("2022-11-14T00:08:54.266024"),
                LocalDateTime.parse("2022-11-17T00:08:56.902252")
            )
        )
            .expectNextCount(4)
            .verifyComplete()
    }
}

А это содержимое файла schema.sql из папки resources.

create table if not exists reviews
(
    id               integer generated by default as identity
        constraint pk_reviews
            primary key,
    text             varchar(3000),
    author           varchar(255),
    created_at       timestamp,
    last_modified_at timestamp,
    course_id        integer
);

А вот содержимое файла reviews.sql из папки resources/data.

truncate reviews cascade;

INSERT INTO reviews (id, text, author, created_at, last_modified_at, course_id) VALUES (-1, 'Amazing, loved it!', 'Anonymous', '2022-11-14 00:08:54.266024', '2022-11-14 00:08:54.266024', 3);
INSERT INTO reviews (id, text, author, created_at, last_modified_at, course_id) VALUES (-2, 'Great, loved it!', 'Anonymous', '2022-11-15 00:08:56.468410', '2022-11-15 00:08:56.468410', 3);
INSERT INTO reviews (id, text, author, created_at, last_modified_at, course_id) VALUES (-3, 'Good, loved it!', 'Sponge Bob', '2022-11-16 00:08:56.711163', '2022-11-16 00:08:56.711163', 3);
INSERT INTO reviews (id, text, author, created_at, last_modified_at, course_id) VALUES (-4, 'Nice, loved it!', 'Anonymous', '2022-11-17 00:08:56.902252', '2022-11-17 00:08:56.902252', 3);

Пожалуйста, обратите внимание на create table if not exists в файле schema.sql и truncate reviews cascade в файле reviews.sql— это необходимо для обеспечения чистого состояния  базы данных для каждого теста.

Теперь, если мы запустим наши тесты, все будет зеленым.

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

@DataR2dbcTest
@Tag("integration-test")
@Target(AnnotationTarget.CLASS)
@ExtendWith(RunSqlExtension::class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
annotation class RepositoryIntegrationTest()

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

Бонус

Но подождите, зачем останавливаться на достигнутом? Имея нашу настройку, мы можем поиграть с компонентными тестами (широкими интеграционными тестами), например, для тестирования наших конечных точек — начиная с простого HTTP-вызова, проходя через все бизнес-слои и сервисы вплоть до уровня базы данных. Вот пример того, как это можно сделать для конечных точек GraphQL.

@ActiveProfiles("integration-test")
@AutoConfigureGraphQlTester
@ExtendWith(RunSqlExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
internal class ReviewGraphQLControllerComponentTest : AbstractTestcontainersIntegrationTest() {

    @Autowired
    private lateinit var graphQlTester: GraphQlTester

    @Test
    @RunSql(["schema.sql", "/data/reviews.sql"])
    fun getReviewById() {
        graphQlTester.documentName("getReviewById")
            .variable("id", -1)
            .execute()
            .path("getReviewById")
            .entity(ReviewResponse::class.java)
            .isEqualTo(ReviewResponseFixture.of())
    }

    @Test
    @RunSql(["schema.sql", "/data/reviews.sql"])
    fun getAllReviews() {
        graphQlTester.documentName("getAllReviews")
            .execute()
            .path("getAllReviews")
            .entityList(ReviewResponse::class.java)
            .hasSize(4)
            .contains(ReviewResponseFixture.of())
    }
}

И снова, если вы хотите повторно использовать все аннотации, просто создайте новую.

@ActiveProfiles("integration-test")
@AutoConfigureGraphQlTester
@ExtendWith(RunSqlExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Target(AnnotationTarget.CLASS)
annotation class ComponentTest()

Вы можете найти код на GitHub.

Удачного кодирования и написания тестов!

Комментарии (2)


  1. AYamangulov
    20.01.2023 14:58

    Еще было бы неплохо показать организацию более сложных тестов, например, данные из reviews.sql добавлять не в каждом тесте, а перед ними, чтобы последовательные тесты с установленным порядком тестов могли модифицировать данные в БД в определенном порядке, когда это требуется, когда последующий тест зависит от предыдущего/предыдущих. Или, скажем, экспорт данных проводить сразу в классе AbstractTestcontainersIntegrationTest , если это возможно?


    1. Sigest
      21.01.2023 07:25

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

      Но отвечая на ваш вопрос, можно чуть подправить Target у анотации RunSql, ну и колбек взять другой