Это второй пост о тестировании Trainer Advisor.
Перед прочтением этого поста стоит ознакомиться с общими идеями и принципами, описанными в первом посте.
А в этом посте я начал описывать работу с самой замороченной частью тестирования — фикстурой в самом общем смысле этого слова - запуск и приведение system under test (sut) к предопределённому состоянию (этот пост) и создание тестовых данных и наполнение ими БД (следующий пост).
На самом верхнем уровне весь процесс состоит из следующих шагов:
Запустить инфраструктуру (сейчас - Postgres и Minio) или сделать для предзапущенной инфраструктуры "factory reset";
Запустить приложение;
Проинициализировать инфраструктуру (схему и бакеты);
Для каждого тест кейса:
Привести данные в БД к эталонным;
Сформировать специфичные данные, которые sut загружает из хранилищ неявно в процессе испытания (вызова метода в блоке Then);
Исходя из принципа "тестирование системы в конфигурации максимально приближенной к боевой" всю инфраструктуру, которой я сам управляю в проде (в случае TA - это Postgres и Minio, но в общем случае это могут быть очереди сообщений, управляемые сервисы и т.д.) я запускаю для тестов в Docker-контейнерах.
Однако так как я работаю по TDD и запускаю тесты по нескольку раз в минуту, мне надо чтобы на запуск теста уходило не более 10 секунд, поэтому пришлось изобрести несколько трюков, сокращающих время инициализации инфраструктуры.
Например — в отличие от (как мне кажется) общепринятой практики, я не использую правила JUnit-а, а создаю контейнеры руками.
Я так делаю, потому что это единственный известный мне способ использовать один контейнер во всех тестах Test Suite-а.
А это позволяет существенно (на вскидку - от двух секунд на тест кейс) сэкономить время за счёт экономии на запуске контейнера и Spring-контекста, который косит на этот контейнер.
Следующая пара оптимизаций реализуется с помощью этих параметров контейнеров:
withTmpFs - создаёт в файловой системе контейнера директорию, которая мапится на оперативную память;
withEnv - настраивает хранилище, на хранение данных в этой директории.
Вместе эти две опции превращают реальный боевой Postgres, например, в фактически in-memory БД.
И благодаря этому выполнение 22 текущих миграций проекта выполняется за 45 миллисекунд, а самый быстрый тест, выполняющий по 3 SELECT-а и INSERT-а, проходит за 20 +/- 5 миллисекунд.
withReuse - настраивает testcontainers на переиспользование контейнер между запусками Test Suite.
Эта настройка изначально позволяла сэкономить несколько секунд на запуске контейнеров, но сейчас стала пережитком и по большому счёту её можно удалить.
Потому что даже с ней просто инициализация testcontainers, чтобы посмотреть, что контейнер уже запущен, занимает порядка 0.5 секунды (или 5% всего временнОго бюджета на тест) на моей машине.
И чтобы срезать эти 0.5 секунды при девелопменте, инфраструктуру для тестов на рабочей машине я запускаю руками.
Для этого у меня есть специализированный Docker Compose проект, который создаёт сервисы на RAM-дисках:
Запускается он командой:
docker compose -f deploy/qyoga/docker-compose-infra-tests.yml up --detach
Для которой у меня в проекте хранится IDEA-вская Shell Script Run Configuration:
Совсем переходить на предзапущенную инфраструктуру я не хочу потому что:
Придётся что-то выдумывать на CI;
Это усложнит опыт новых разработчиков — сейчас разработку можно начать буквально за три действия — зачекаутить проект, открыть его в идее, запустить main-метод или нужный тест.
Поэтому у меня есть ещё кусочек специализированного кода, для определения URL-ов подключения к инфраструктуре.
Например, определение URL подключения к Postgres выглядит так:
Здесь определяется несколько констант подключения к предзапущенному Postgres и две ленивых переменных - jdbcUrl и testDataSource.
Вся магия происходит при инициализации jdbcUrl.
Сначала выполняется попытка подключения к предзапущенному Postgres.
Если подключение проходит - в этом Postgres-е пересоздаётся БД и далее для подключения к БД используется URL предзапущенного Postgres.
Если нет - идёт обращение к URL тест-контейнера через также ленивую переменную pgContainer.
В этот момент фактически запускается контейнер, и Postgres в нём инициализируется скриптом, создающим пустую БД qyoga.
В итоге, обратившись к jdbcUrl, мы получаем URL, который косит на запущенный Postgres с пустой БД qyoga.
Далее этот URL используется для создания экземпляра DataSource, который через специальный Spring-конфиг будет подложен бином в контекст приложения, запущенного в тестах.
В случае Minio схема практически такая же:
Единственное что, для Minio удаление бакетов выполняется руками в обоих случаях.
С запуском приложения примерно та же история, что и с инфраструктурой - я запускаю практически такой же Spring-контекст, как и в проде, но с некоторыми приседаниями во имя скорости запуска.
TestPasswordEncoderConfig - конфиг, переопределяющий PasswordEncoder в продовом контексте на Noop, так как продовый BCrypt хэширует пароли при логине по 300мс, а у меня практически каждый тест начинается с логина;
TestDataSourceConfig и TestMinioConfig - конфиги, переопределяющие DataSource и MinioClient в продовом контексте, на объекты, созданные руками.
При запуске приложения аналогичным продовому образом выполняется инициализация хранилищ - создание схемы БД Postgres и бакетов Minio.
Создание схемы БД выполняется автомагически.
А бакеты инициализируют бины, отвечающие за эти бакеты в своих @PostConstruct-методе:
Итого перед началом выполнения первого метода тест кейса мы имеем:
Запущенные контейнеры Postgres и Minio, состояние которых сброшено до исходного (как минимум перед запуском текущего набора тестов);
Запущенное приложение, в конфигурации, максимально приближенной к боевой;
Заранее детерминированное и всегда идентичное исходное наполнение БД.
Теперь остался последний этап сетапа фикстуры — создание и, при необходимости, вставка данных, специфичных для теста.
Его я рассмотрю в следующем посте.
В Trainer Advisor всей это инфраструктуры (пока?) нет, но 2024 году пост об интеграционном тестировании, без описания тестирования интеграций с внешними сервисами и брокерами сообщений нельзя считать полноценным.
Поэтому в качестве бонус-трека, я расскажу о том, как мы тестировали такие интеграции в Проекте Э.
В Проекте Э, как и всех других своих проектах за последние четыре года, для тестирования кода, работающего с внешними системами по HTTP, я использовал WireMock.
C JUnit 5 он интегрируется достаточно просто.
В первую очередь, необходимо настроить sut на обращение к localhost:<wireMockPort>.
В Spring Boot это можно сделать с помощью application-test.yaml-файла - файла конфигурации, который будет загружен при запуске приложения с профилем test и переопределит настройки из основного application.yaml:
А затем, с помощью стандартного расширения для JUnit надо настроить запуск WireMock-сервера на том же порту, что прописан в тестовой конфигурации:
После чего, к методам тест-кейсов можно будет добавить параметр типа WireMockRuntimeInfo и использовать его для сетапа моков запросов:
В Проекте Э по историческим причинам значительная часть общения внутри приложения шла через RabbitMQ.
Соответственно, многим тестам для работы нужен Кролик и он запускается практически так же, как и Postgres в Trainer Advisor:
Тут контейнер создаётся не на RAM-диске.
Сейчас наверняка не помню - было ли это осознанное решение, или контейнер сетапил юниор в мыле старта реинжиниринга и так и осталось.
Далее используется Spring Context Initializer, который прокидывает параметры подключения (в первую очередь - случайный порт) в контекст:
При этом Кролик будет запущен при первом обращении к rabbitMqContainer.
Далее, этот инициалайзер прописывается в базовом классе тестов:
После чего тесты, которым нужен Кролик, наследуются от него, регистрируют своего слушателя и через него смотрят, что SUT отправил то, что надо - подробнее об этом в следующем посте.
Фичу, которая включала отправку данных в Кафку внешней системы, делал юниор и под давлением сроков, поэтому в тестировании этой фичи есть нюанс: в отличие от тестов с Кроликом, тестовый слушатель сделан Spring Bean-ом и, соответственно, один общий на все тесты.
Сама Кафка запускается в контейнере практически так же, как и Кролик:
Далее в дело также вступает Context Initializer:
Здесь, помимо проброса параметров подключения к Кафке, перед каждым запуском набора тестов удаляется единственный топик, в который отправляются сообщения для сброса состояния.
А вот дальше начинаются отличия от сетапа тестов с Кроликом.
Во-первых, в тестах определяется общий бин слушателя:
Во-вторых, определяется конфиг, который добавляет в контекст Кафковую инфраструктуру и этот слушатель:
Далее, контейнер запускается так же, как и в Кролике - через регистрацию Context Initializer в базовом тесте, а в конкретных тестах дополнительно прописывается конфиг Кафки и инжектится тестовый лисенер:
По историческим причинам одна из фич в ядре Проекта Э зависит от другого внутреннего сервиса, написанного на Node.js.
Сам сервис собирается в контейнер и публикуется в приватный реестр.
Поэтому для тестов этой фичи он запускается по стандартной уже схеме: