блог разработчика о насущном

Переход на Testcontainers

Часть вторая
Проект растёт, в схему данных добавляются новые таблицы, на функционал пишутся новые тесты. Соответственно, продолжает расти и время сборки. Если на момент первой публикации полный rebuild занимал 1 мин 30 сек, то теперь уже это время составляет 4 мин 30 сек (я округляю все числа для лучшего восприятия).

Впереди на отметке 5 минут маячит психологический рубеж (впрочем, у каждого он тоже свой). Нужно что-то делать!
Перечитаем предыдущую часть статьи и исходный код и составим плюсы и минусы текущего подхода.

Плюсы:
  • Перед запуском тестов поднимается база данных, которая максимально повторяет боевую: тот же софт (MySQL 8.0), та же структура и типы данных (выполняются строго те же миграции Liquibase).
  • Транзакции выполняются быстро, так как данные располагаются в оперативной памяти, а не на диске.
  • Перед каждым тестом имеется «чистое» окружение — все таблицы девственно чисты.
Минус, фактически, только один — после каждого теста (130+) мы не знаем, какие таблицы не пусты, и вызываем TRUNCATE на всех (50+) таблицах. За сборку получается около 7000 запросов TRUNCATE. Мне захотелось уменьшить это число.
Как мы уже знаем, можно прочитать количество строк из таблицы information_schema. TABLES, но с ней имеется проблема — она ленивая. Значение в ней обновится только после того, как на таблице будет вызван ANALYZE или выполнен какой-нибудь DDL. Мы не можем этим воспользоваться.

С другой стороны, у движка InnoDB, который MySQL использует «под капотом» по умолчанию, имеются собственные служебные таблицы, которые даже не хранятся на диске, а считываются напрямую из памяти — значит данные в них могут иметь мгновенные значения, а не ленивые. Вот эти таблицы:
логирование java-разработчик баги rocket science
Меня заинтересовала таблица INNODB_TABLESTATS:
Интерес представляют следующие колонки: NUM_ROWS — текущее количество строк в таблице, AUTOINC — значение AUTO INCREMENT. Забегая вперёд отмечу, что NUM_ROWS в моей реализации также повела себя лениво (скорее всего на стороне СУБД имеется какая-то гонка и оно просто не успевает обновиться до того, как я делаю SELECT из этой таблицы после теста). А вот на AUTOINC, по разумным соображениям, должен существовать лок, обеспечивающий синхронизированный доступ. К тому же забавно, что после создания таблицы или вызова TRUNCATE на ней это значения сбрасывается в 0, а после вставки первой строки оно становится равным 2.

Упрощённо, алгоритм действий получился следующий:
  • В базовом классе для тестов я определил метод с аннотацией @AfterAll, который делает всю работу.
  • Метод сперва получает список всех таблиц в приложении. Это ленивая и однократная операция, т. к. набор таблиц не меняется во время тестирования. Запрос выполняется к таблице information_schema.TABLES, забираем в множество названия всех тех таблиц, которые лежат в тестовой схеме. Так мы их посчитали, а заодно слегка отфильтровали (убрали неинтересные DatabaseChangelog, DatabaseChangelogLock, ShedLock).
  • Выполняется запрос в таблицу information_schema.INNODB_TABLESTATS, читаем только колонки NAME и AUTOINC, из результата выбираем только те, у которых AUTOINC больше нуля. Кроме того, фильтруем таблицы по схеме и имени.
  • Каждой таблице из результирующего списка вызываем TRUNCATE. Это не только удаляет из неё все данные, но и сбрасывает AUTOINC обратно в ноль.
Теоретически, да и практически, с кодом всё в порядке — он рабочий. Только при первом же запуске появляется новая проблема:


Access denied; you need (at least one of) the PROCESS privilege(s) for this operation.


Оказывается, что в Testcontainers при использовании базы данных через строку JDBC-подключения невозможно поменять имя пользователя. По крайней мере это справедливо для MySQL. При этом в запускаемом контейнере имеется два пользователя: root и test (по умолчанию), оба с паролем test (по умолчанию), и оба могут подключаться с любого хоста. Пользователь test не имеет никаких специальных прав, root, естественно, имеет их все. Поэтому пришлось отказаться от конфигурирования контейнера через jdbc-url и создать отдельную тестовую конфигурацию, которая будет поднимать контейнер при создании DataSource, и использовать учётную запись root/test:
 @TestConfiguration
public class DatabaseTestConfig {

    @UtilityClass
    public static class MySql {

        public final String IMAGE = "mysql:8.0";

        public final String ROOT_USERNAME = "root";
        public final String USERNAME = "user";
        public final String PASSWORD = "test";
        public final String DATABASE = "test";
    }

    private static final MySQLContainer<?> DATABASE_CONTAINER = new MySQLContainer<>(MySql.IMAGE);

    @Primary @Bean
    public DataSource testDataSource() {
        DATABASE_CONTAINER
                .withTmpFs(Map.of("/var/lib/mysql", "rw"))
                .withUrlParam("serverTimezone", "Asia/Novosibirsk")
                .withEnv("MYSQL_ROOT_PASSWORD", MySql.PASSWORD)
                .withEnv("MYSQL_ROOT_HOST", "%")
                .withUsername(MySql.USERNAME)
                .withPassword(MySql.PASSWORD)
                .withDatabaseName(MySql.DATABASE)
                .start();
        return DataSourceBuilder.create()
                .url(DATABASE_CONTAINER.getJdbcUrl())
                .username(MySql.ROOT_USERNAME)
                .password(MySql.PASSWORD)
                .build();
    }
}
Заключение
Подведём итоги. Я затратил около 6 часов на изучение материалов, кодинг и отладку. За сборку проекта выполнено 700 запросов TRUNCATE (уменьшение на 90%). Среднее время сборки уменьшилось с 4 мин 30 сек до 2 мин 40 сек (уменьшение на 40%). По-моему, это замечательный результат.