Как стать автором
Обновить
105.02
Холдинг Т1
Многопрофильный ИТ-холдинг

Один контейнер, чтобы править всеми — пока всё не сломается

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров562
Автор оригинала: pfilaretov42


TL;DR


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




Привет! Меня зовут Петр, я работаю в Т1 Архитектором платформенных решений.


При работе с Testcontainers я обнаружил несколько не вполне очевидных моментов. Особенно, если вы только начинаете работать с этой библиотекой. Давайте разберёмся.


Полный исходный код доступен на GitHub: pfilaretov42/spring-testcontainers-tips.


Остановка контейнера


API


Допустим, у нас есть Spring Boot-приложение с эндпоинтами для создания и получения Колец:


@RestController
@RequestMapping("/rings")
class RingController(
    private val celebrimbor: ElvenSmith<Ring>,
) {

    @PostMapping
    fun forge(): Unit {
        celebrimbor.forgeTheThreeRings()
    }

    @GetMapping
    fun getAll(): List<String> {
        return celebrimbor.getAllTreasures().map { it.name }
    }
}

В нашем случае «создание» означает простое сохранение сущности в PostgreSQL:


@Service
class Celebrimbor(
    private val treasury: RingTreasury,
) : ElvenSmith<Ring> {

    @Transactional
    override fun forgeTheThreeRings() {
        treasury.save(Ring(name = "Narya"))
        treasury.save(Ring(name = "Nenya"))
        treasury.save(Ring(name = "Vilya"))
    }

    override fun getAllTreasures(): List<Ring> = treasury.findAll().toList()
}

interface RingTreasury : CrudRepository<Ring, UUID>

Тест


Теперь мы хотим написать тесты для этого API с помощью @SpringBootTest. Чтобы Spring-контекст запускался только один раз для всего набора тестов, добавим абстрактный тестовый класс в качестве основы для всех тестов:


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {
}

Для работы с базой данных мы будем использовать Testcontainers. Существует несколько способов его запуска, включая аннотации JUnit4, аннотации JUnit5, ApplicationContextInitializer, JDBC URL-схему и ручное управление жизненным циклом контейнера.


Давайте выберем ручное управление, как наименее «магический» вариант:


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {

    companion object {
        private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))

        init {
            logger.info { "STARTING CONTAINER" }
            postgresContainer.start()
        }

        @JvmStatic
        @AfterAll
        fun tearDown() {
            logger.info { "STOPPING CONTAINER" }
            postgresContainer.stop()
        }

        // ...
    }

    // ...
}

Здесь у нас есть postgresContainer, который запускается при загрузке тестов (блок init) и останавливается после их выполнения (метод @AfterAll).


Теперь добавим тест, который наследуется от AppAbstractTest...


class RingsApiTest : AppAbstractTest() {

    @Test
    fun `should forge the three rings`() {
        logger.info { "TEST: should forge the three rings" }
        testRestTemplate.postForObject<Unit>(endpointUrl, "{}")
        val list = testRestTemplate.getForObject(endpointUrl, List::class.java)
        assertThat(list.size).isEqualTo(3)
    }

    // ...
}

… и запустим его в IDE. Тест успешно проходит, а в консоли мы видим следующие логи:


INFO AppAbstractTest -- STARTING CONTAINER
...
INFO RingsApiTest   : TEST: should forge the three rings
...
INFO AppAbstractTest: STOPPING CONTAINER

(Здесь и далее я буду убирать незначимые части логов для лучшей читаемости)


Пока всё хорошо.


Ещё API


Хорошо, давайте добавим ещё один API (с которого, возможно, нам следовало бы начать) — для создания Сильмарилей:


@RestController
@RequestMapping("/silmarilli")
class SilmarilliController(
    private val fëanor: ElvenSmith<Silmaril>,
) {

    @PostMapping
    fun craft(): Unit {
        fëanor.craftSilmarilli()
    }

    @GetMapping
    fun getAll(): List<String> {
        return fëanor.getAllTreasures().map { it.fate }
    }
}

Здесь «создание» снова означает простое сохранение сущности в базе данных, но в другой таблице:


@Service
class Fëanor(
    private val treasury: SilmarilTreasury,
) : ElvenSmith<Silmaril> {

    @Transactional
    override fun craftSilmarilli() {
        treasury.save(Silmaril(fate = "Air"))
        treasury.save(Silmaril(fate = "Earth"))
        treasury.save(Silmaril(fate = "Water"))
    }

    override fun getAllTreasures(): List<Silmaril> = treasury.findAll().toList()
}

interface SilmarilTreasury : CrudRepository<Silmaril, UUID>

Ещё один тест


Теперь добавим тест для API Сильмарилей. Он будет во многом похож на тест API Колец:


class SilmarilliApiTest : AppAbstractTest() {

    @Test
    fun `should craft Silmarilli`() {
        logger.info { "TEST: should craft Silmarilli" }
        testRestTemplate.postForObject<Unit>(endpointUrl, "{}")
        val list = testRestTemplate.getForObject(endpointUrl, List::class.java)
        assertThat(list.size).isEqualTo(3)
    }

    // ...
}

Тест проходит успешно, и мы получаем следующие логи:


INFO AppAbstractTest -- STARTING CONTAINER
...
INFO SilmarilliApiTest: TEST: should craft Silmarilli
...
INFO AppAbstractTest  : STOPPING CONTAINER

Теперь давайте проверим, что проект собирается с помощью Gradle (кстати, вот мой микро-пост о том, как включить логи тестов для Gradle-сборки):


./gradlew clean build

И сборка падает:


2 tests completed, 1 failed

Что происходит? Давайте посмотрим логи сборки:


INFO AppAbstractTest -- STARTING CONTAINER
...
INFO RingsApiTest       : TEST: should forge the three rings
...
INFO AppAbstractTest    : STOPPING CONTAINER
...
INFO SilmarilliApiTest  : TEST: should craft Silmarilli
WARN com.zaxxer.hikari.pool.ProxyConnection   : HikariPool-1 - Connection org.postgresql.jdbc.PgConnection@64381526 marked as broken because of SQLSTATE(08006), ErrorCode(0)
org.postgresql.util.PSQLException: An I/O error occurred while sending to the backend.
...
ERROR o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.TransactionSystemException: JDBC rollback failed] with root cause
java.sql.SQLException: Connection is closed
...

Ага! Мы видим, что контейнер с Postgres запускается перед всеми тестами, но останавливается после выполнения RingsApiTest, а не после всех тестов. И поэтому SilmarilliApiTest падает — приложение не может подключиться к базе данных.


При этом новый контейнер для SilmarilliApiTest не создаётся. Это происходит потому, что мы запускаем контейнер в статическом (companion object) блоке init, а класс загружается один раз для всех тестов.


Как это исправить? Кажется, просто: контейнер не запускается для SilmarilliApiTest? Тогда давайте запустим его в методе @BeforeAll!


Теперь наш класс AppAbstractTest будет выглядеть так:


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {

    companion object {
        private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))

        @JvmStatic
        @BeforeAll
        fun setUpAll() {
            logger.info { "STARTING CONTAINER" }
            postgresContainer.start()
        }

        @JvmStatic
        @AfterAll
        fun tearDown() {
            logger.info { "STOPPING CONTAINER" }
            postgresContainer.stop()
        }

        // ...
    }

    // ...
}

Запускаем Gradle-сборку снова, и что мы видим? Второй тест снова падает:


INFO AppAbstractTest -- STARTING CONTAINER
...
INFO RingsApiTest       : TEST: should forge the three rings
...
INFO AppAbstractTest    : STOPPING CONTAINER
...
INFO AppAbstractTest    : STARTING CONTAINER
...
INFO SilmarilliApiTest  : TEST: should craft Silmarilli
WARN com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@5513a46b (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
...
ERROR o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.CannotCreateTransactionException: Could not open JDBC Connection for transaction] with root cause
java.net.ConnectException: Connection refused

Хорошо, теперь контейнер действительно запускается для SilmarilliApiTest. Однако тестируемое приложение не может к нему подключиться 🤔


Это происходит потому, что Spring-контекст запускается один раз для всего набора тестов (благодаря AppAbstractTest), и данные подключения ко второму контейнеру не подхватываются. Поэтому в тесте SilmarilliApiTest возникают проблемы с подключением.


Похоже, нам нужно переиспользовать первый контейнер для всех тестов. И тут нам, наверное, может помочь фича Testcontainers, которая называется reusable containers. Выглядит многообещающе, давайте попробуем!


Reusable Containers


Документация по reusable containers говорит:


start the container manually by calling start() method, do not call stop() method directly or indirectly

Выглядит просто, нам нужно убрать методы @BeforeAll и @AfterAll и вернуть блок init:


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {

    companion object {
        private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))

        init {
            logger.info { "STARTING CONTAINER" }
            postgresContainer.start()
        }

        // ...
    }

    // ...
}

Затем нам нужно включить reusable containers. Согласно той же документации надо:


  • Добавить следующую строку в файл ~/.testcontainers.properties:
    testcontainers.reuse.enable=true
  • Подписаться на повторное использование в определении контейнера:
    private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
    .withReuse(true)

Вот и всё!


Запускаем Gradle-сборку и… она проходит успешно! 🎉 В логах мы видим:


INFO AppAbstractTest -- STARTING CONTAINER
...
INFO SilmarilliApiTest : TEST: should craft Silmarilli
...
INFO RingsApiTest      : TEST: should forge the three rings

Итак, контейнер запускается перед первым тестом и переиспользуется для второго теста. Отлично!


Давайте снова запустим сборку, просто на всякий случай. И теперь она падает 😖


2 tests completed, 2 failed

В отчёте о тестах есть подробности:


AssertionFailedError: 
expected: 3
 but was: 6

Assert не прошёл для следующего кода:


assertThat(list.size).isEqualTo(3)

Похоже, это происходит потому, что контейнер, запущенный при первом выполнении тестов, вообще не останавливается. Он продолжает работать, и база данных всё ещё содержит три Кольца и три Сильмариля, добавленных в процессе тестов. Когда все тесты выполняются второй раз, они добавляют ещё три Кольца и три Сильмариля, что приводит к падению assert'a.


Как же нам обеспечить стабильное выполнение всех тестов? 🤔


Исправление тестов


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


Очистка базы данных


Нам нужно удалять данные в базе перед каждым тестом:


class RingsApiTest : AppAbstractTest() {

    @BeforeEach
    fun setUp() {
        ringTreasury.deleteAll()
        // ...
    }

    // ...
}

class SilmarilliApiTest : AppAbstractTest() {

    @BeforeEach
    fun setUp() {
        silmarilTreasury.deleteAll()
        // ...
    }

    // ...
}

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


Не использовать reusable containers


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


Кроме того, «Reusable» — это экспериментальная фича, которую надо использовать с осторожностью. Вот что говорит документация:


Reusable containers are not suited for CI usage and as an experimental feature not all Testcontainers features are fully working (e.g., resource cleanup or networking).

Итак, мы настроим Testcontainers на создание нового контейнера для каждого запуска тестов. Для этого нам нужно:


  • удалить свойство testcontainers.reuse.enable из файла ~/.testcontainers.properties;
  • удалить свойство withReuse(true) у контейнера;
  • запускать контейнер один раз в блоке init;
  • не останавливать контейнер для каждого тестового класса, Ryuk позаботится о его остановке в конце работы всех тестов.

Вот как теперь выглядит абстрактный тест:


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {

    companion object {
        private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))

        init {
            logger.info { "STARTING CONTAINER" }
            postgresContainer.start()
        }

        // ...
    }

    // ...
}

Теперь запускаем сборку Gradle и… она проходит успешно 🎉 Мы можем запускать её снова и снова, и тесты каждый раз проходят.


Заключение


Мы рассмотрели несколько способов использования Testcontainers, и вот основные выводы:


  • Если вы используете @SpringBootTest так, что создается один Spring-контекст на все тесты, скорее всего, вам не нужен отдельный контейнер для каждого тестового класса или для каждого теста. Просто запустите контейнер один раз для всего набора тестов.
  • Не останавливайте контейнер, оставьте это на:
    • Ryuk, если вы запускаете контейнер вручную;
    • или расширение JUnit Jupiter, если используете аннотации @Testcontainers и @Container.
  • Не используйте reusable containers без необходимости. Убедитесь, что вы понимаете преимущества и недостатки этого подхода как для локальной разработки, так и для CI/CD-конвейеров.
  • Очищайте (или правильно подготавливайте) данные в базе данных перед каждым тестом.
Теги:
Хабы:
+4
Комментарии0

Публикации

Информация

Сайт
t1.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
ИТ-холдинг Т1