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

Переход на Testcontainers, вызванный одной очень длинной компанией

Всем привет. Речь ниже пойдёт о небольшом пойманном баге на проекте бэкенда для мобильных приложений, которыми пользуются сотрудники одного из наших клиентов — крупной финансовой организации. В общем-то, highload-а и никаких больших данных на проекте нет: немногочисленные пользователи (10к) разнесены по разным часовым поясам нашей страны и на сервер в часы пик приходит не более 15 запросов/сек.
Введение
В очередной зимне-весенний понедельник на бою поймали баг. Сотрудник пытается завершить определённый бизнес-процесс, а сервер отвечает ему ошибкой. Оказалось, что название юридического лица, с которым взаимодействует сотрудник, не влезает в 255 символов.
Сервер представляет собой традиционное Spring Boot приложение. Для версионирования структуры базы данных используется liquibase. СУБД — MySQL 8. Ничего сложного в исправлении бага нет: создать новый xml-файл с одним changeset-ом modifyDataType. Все тесты зелёные. Запускаю приложение локально — не поднимается, сообщает о том, что у MySQL есть свой взгляд на индексы (а это поле должно иметь индекс, потому что по нему руководители сотрудников могут фильтровать бизнес-процессы в админке).
Имейте ввиду, что modifyDataType убивает ограничение NOT NULL. Если оно вам нужно, не забудьте его впоследствии вернуть на место.
MySQL имеет лимит на индекс по текстовой колонке, равный 767 символов (это если utf8, а если utf8mb4, который на целый байт больше, то всего 191 знак). Решение ограничить индекс по N первых символов кажется приемлемым, поэтому добавляем две дополнительные миграции: на первое место ставим миграцию с удалением индекса с колонки, а после изменения длины колонки — повторное создание индекса с ограничением. Теперь приложение запускается и работает успешно.

Но теперь все тесты покраснели. В какой-то момент в начале проекта мы приняли решение прогонять тесты в in-memory СУБД HSQLDB в режиме эмуляции синтаксиса MySQL. И он не поддерживает синтаксис создания индекса с ограничением по длине поля. Вижу из этой ситуации два решения. Первое: поскольку мы используем liquibase, то можно у каждого changeset-а указывать явно dbms, к которому относится данная миграция. Создаём индекс с ограничением по длине для dbms="mysql" и ровно такой же, как и был раньше — для dbms="hsqlbd". Очень быстрое решение, но только мне не нравится, что с этого момента структура данных в тестах ещё сильнее "уходит" от своего боевого собрата.

Второе решение, которое давно хотелось попробовать: замена HSQLDB на Testcontainers и прогон тестов в реально идентичной базе!
Переезжаем на Testcontainers
Prerequisites. Вам понадобится установленный Docker на машинах всех разработчиков (ну или тех, кто хочет прогонять тесты) и в вашем CI/CD. Я сижу на Windows, поэтому скачал и установил Docker for Windows. С предыдущей попытки использовать его локально прошло больше полутора лет, система была недавно переустановлена, поэтому установка прошла гладко. В прошлый раз я испытывал трудности с Docker под эту ОС и мне постоянно приходилось делать ему сброс настроек.

Далее открываю документацию Testcontainers и делаю всё по шагам. Добавляю в pom.xml проекта три новые зависимости в тестовом scope (org.testcontainers:testcontainers, org.testcontainers:junit-jupiter и org.testcontainers:mysql). В application-test.yaml указываю jdbc-драйвер org.testcontainers.jdbc.ContainerDatabaseDriver и саму строку подключения: jdbc:tc:mysql:8.0:///emp-unit-tests. It's alive! Запускаю сборку, вижу в консоли подключение к Docker-у, скачивание образа с СУБД Docker Hub-а и прогон тестов. Но почему всё так медленно?!?

HSQLDB — in-memory database, соответственно, он очень быстр, и скорость его работы не была ограничивающим фактором к использованию аннотации @DirtiesContext. Это, в свою очередь, приводит к созданию нового контекста после прогона "грязных" тестов, то есть тех, которые по мнению разработчика могут хоть как-то повлиять своими данными на выполнение других тестов. Уверен, знакомая многим ситуация. Теперь с новым контекстом в docker-е каждый раз поднимается новый контейнер с MySQL (примерно 15-25 секунд, хотя на Linux-е я бы ожидал, что это будет быстрее) и выполнение всех миграций (за 9 месяцев разработки у них накопилось примерно 150 changeset-ов). До изменений тесты проходили в среднем за 90 секунд, а сейчас я просто останавливаю сборку примерно после 8 минут, так и не дождавшись результата!

Переписывать все тесты, чтобы они сами чистили за собой данные — долго, нудно, не хочется и не страхует от появления "грязных" тестов в будущем. Тут я обратил своё внимание на то, что все тесты, независимо от того, что они после себя оставляют, устраивает одно условие: абсолютно чистая схема данных с пустыми таблицами до прогона теста.
логирование java-разработчик баги rocket science
Новое решение не заставило себя долго ждать. В проекте каждый тест наследуется от BaseApplicationTest, в котором описано, как поднимать тестовый контекст, какие тестовые конфигурации подгружать и т.п. Это стало отправной точкой, чтобы написать @AfterEach-метод, который будет чистить после каждого теста все таблицы. Я решил использовать такой подход — получаем из information_schema перечень имеющихся в схеме таблиц:
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = :schema
А затем в цикле выполняем запрос TRUNCATE TABLE :table. Теперь можно избавиться от всех @DirtiesContext, поскольку сам по себе проект — stateless-монолит, из которого всё состояние как раз таки и вынесено в СУБД, а БД у нас будет очищаться после каждого теста. Также в тестах выключено кеширование.

Решение мне понравилось, но запуск тестов опять намекнул на незаконченность: в проекте более 20 таблиц, и вызывать на каждой из них TRUNCATE после каждого теста совершенно не быстро — дисковые операции в docker-е на виртуалке.
⚠️ Внимание! Впереди тупиковая ветвь развития сюжета.

Следующая мысль — каждый тест использует минимум таблиц, значит, большинство остальных уже пусты и их не стоит пытаться очищать. Меняем запрос на следующий:
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = :schema AND TABLE_ROWS > 0
Больше половины тестов упало. Плюсом наблюдается интересная ситуация: после каждого теста пытается очиститься только одна таблица, в которую значения записывались в миграциях. Через пару часов выяснилось, что вся мета-таблица information_schema на самом деле ленивая, и чтобы увидеть в колонке TABLE_ROWS актуальные значения, нужно сделать ANALYZE TABLE или вызвать какой-нибудь DDL. Не подходит, а значит, придётся всё равно очищать все имеющиеся таблицы.

Дальнейшее изложение приводит к успеху.

Давайте снова обратимся к документации Testcontainers. Не может быть, чтобы разработчики не сталкивались с вопросами производительности контейнеров и не пытались их как-то решить. И действительно — можно указать любой каталог внутри контейнера для расположения в оперативной памяти. Воспользуемся этим и поместим в tmpfs каталог с данными MySQL: /var/lib/mysql:
spring:
  datasource:
    url: jdbc:tc:mysql:8.0:///emp-unit-tests?TC_TMPFS=/var/lib/mysql:rw
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
Объединив все имеющиеся на данный момент подходы — очистку всех таблиц схемы и монтирование каталога с данными в оперативную память — получаем зелёные тесты за примерно те же самые полторы минуты. Отличный результат!
Заключение
На самом деле не все тесты сразу стали зелёными. Я нашёл несколько из них, которые не настраивали должным образом моки на другие сервисы.

По какому-то случайному стечению обстоятельств соседние тесты переиспользовали стабы, пропускали verify и творили полное нечто. Это прощалось благодаря @DirtiesContext, который честно пересоздавал новый контекст с новыми моками. Количественно тестов на проекте не так уж и много, поэтому я в спокойном режиме примерно за один день исправил все неправильные.

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