Как поднимать свежую фикстуру БД на 300+ таблиц за 1.5 секунды

July 3, 2024

Введение

Я сторонник тестов с минимумом моков и сделал это одним из столпов Эргономичного подхода. Среди прочего, это влечёт использование реальной БД в тестах - известного источника проблем с их скоростью и стабильностью. В целом я нашёл схему подготовки БД, которая решает обе эти проблемы.

Однако на Проекте Р стал серьёзным испытанием для моей схемы.

По определённым причинам, мы решили делать Проект Р поверх БД Проекта У.

А самому Проекту У восемь лет, за которые он разжился 300+ таблицами, которые создаются 1389 миграциями. На моей машине раскатать такую схему занимает порядка 25 секунд. Но это полбеды, просто на то, чтобы понять, что докатывать ничего не надо, Liquibase требуется 4 секунды.

И если работать по ТДД, даже 4 секунды только на подготовку БД, даже один раз на тест ран - это слишком много. Поэтому я немного повозился и ужал это время до 1.5 секунд на весь сетап свежей БД - от инициализации контейнера до завершения работы Liqubase.

Рассказываю, как я это сделал.

Реюз контейнера

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

В отличие от Trainer Advisor, в Проекте Р я отказался от приседаний с предапуском контейнеров в дев окружении, но оставил запуск контейнера руками и Spring-конфиг для проброса порта:

val pgContainer: PostgreSQLContainer<*> by lazy {
    PostgreSQLContainer("postgres:16.3")
        .withExposedPorts(5432)
        .withTmpFs(mapOf("/var" to "rw"))
        .withUsername("postgres")
        .withEnv("PGDATA", "/var/lib/postgresql/data-not-mounted")
        .withInitScript("schema.sql")
        .withReuse(true)
        .withCopyFileToContainer(
            MountableFile.forClasspathResource("db/project-u-db-dump.sql"),
            "/docker-entrypoint-initdb.d/init.sql"
        )
        .apply {
            start()
            // Сначала подключаемся к бд postgres, пересоздаём бд project-u для обнуления фикстуры и подключаемся к ней
            this.withDatabaseName("project-u")
        }
}

class TestContainerDbContextInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {

    override fun initialize(applicationContext: ConfigurableApplicationContext) {
        applicationContext.environment.propertySources.addFirst(
            MapPropertySource(
                "Integration Postgres test properties",
                mapOf(
                    "spring.datasource.url" to pgContainer.jdbcUrl,
                    "spring.datasource.username" to pgContainer.username,
                    "spring.datasource.password" to pgContainer.password,
                )
            )
        )
    }

}

Но вскоре после этого я обнаружил, что в поддержку jdbc урлов в testcontainers, завезли и опцию реюза, и опцию tmpfs и планирую в ближайшее время заменить весь код выше на одну строку:

spring:
  datasource:
    url: "jdbc:tc:postgresql:16.3:///project-u?TC_REUSABLE=true&TC_TMPFS=/var:rw"

RAM-диск

Как я уже спойлернул в коде выше, база у меня работает на RAM-диске.

Однако, для Проекта Р этого оказалось недостаточно и прогон 1К миграций даже на РАМ-диске требовал какого-то невменяемого времени (замеров у меня не сохранилось, и, пользуясь форматом микропоста, не буду их заново делать).

DATABASE TEMPLATE

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

Дамп ситуацию улучшил (тут у меня есть хоть какие-то замеры - на этом этапе тест целиком - от нажатия кнопки запуска до завершения работы JVM - проходил за 9 секунд), но результатом я всё ещё остался недоволен.

Тогда я вспомнил про Postgres Template Databases - это возможность Postgres создать фактически копию БД, простым копированием директории в файловой системе (на RAM-диске).

Для этого я перетащил дамп в init-скрипт самого контейнера (который выполняется один раз при создании контейнера):

.withCopyFileToContainer(
    MountableFile.forClasspathResource("db/project-u-db-dump.sql"),
    "/docker-entrypoint-initdb.d/init.sql"
)

А в init-скрипте уже самих testcontainers (который, при реюзе, выполняется при каждой инициализации тест-контейнера) я просто пересоздаю БД из шаблона:

DROP DATABASE IF EXISTS project_u;

CREATE DATABASE project_u TEMPLATE project_u_template;

Это срезало мне 3 секунды и после этого финта на запуск теста стало уходить 6 секунд.

Но мы можем ещё лучше.

Сокращённые миграции

Немного помедитировав, я подумал: "Если у меня есть фиксированный дамп от 24-ого года - зачем мне проверять миграции 16-23 годов?". Подумал и завёл в тестовых исходниках ченджлог-файл с миграциями только 24-ого года.

Это мне срезало ещё 2 секунды и в итоге, на запуск теста со свежей БД на 300+ таблиц стало уходить 4 секунды - от нажатия кнопки запуска теста, до выхода JVM.

Заключение

Сейчас в Проекте Р на запуск одного теста требуется порядка 4-5 секунд на моей машине, что вполне подходит для разработки в ТДД-стиле.

Из этих 4-5 секунд, на подготовку свежей БД на 300+ таблиц уходит ~1.5 секунды - ~0.5 секунды на инициализацию (переиспользование) testcontainer-а, ~0.2 секунды на создание БД из шаблона и ~0.7 секунд на проверку 21 и запуск 2 миграций (и тут Liquibase явно тормозит - Flyway несколько десятков SQL-миграций может прогнать за 200мс):

2024-07-03 10:47:29.698 uid= [main] INFO  o.testcontainers.images.PullPolicy - Image pull policy will be performed by: DefaultPullPolicy()
2024-07-03 10:47:30.233 uid= [main] INFO  tc.postgres:16.3 - Container is started (JDBC URL: jdbc:postgresql://localhost:32774/test?loggerLevel=OFF)
...
2024-07-03 10:47:30.236 uid= [main] INFO  org.testcontainers.ext.ScriptUtils - Executing database script from schema.sql
2024-07-03 10:47:30.406 uid= [main] INFO  org.testcontainers.ext.ScriptUtils - Executed database script from schema.sql in 170 ms.
...
2024-07-03 10:47:31.479 uid= [main] INFO  liquibase.database - Set default schema name to public
2024-07-03 10:47:32.162 uid= [main] INFO  liquibase.command - Command execution complete

Итого, для того чтобы поднять свежую БД на 300+ таблиц за 1.5 секунды, надо:

  1. Переиспользовать тест-контейнер с БД;
  2. Запускать тест-контейнер на RAM-диске;
  3. Использовать более-менее свежий дамп в качестве шаблона БД;
  4. Прогонять только свежие миграции.