Интеграционные тесты и Testcontainers
Взял H2 - лёгкую базу, которая живёт прямо в памяти процесса, - и прогнал через неё семь запросов, написанных на диалекте PostgreSQL, то есть с использованием его собственных возможностей поверх общего SQL. Пять прошли: регистронезависимое сравнение, склейка строк в одну, выборка по первому в группе, генерация ряда чисел. Два упали: работа с массивами и тип JSONB.
Вот почему подмена базы на H2 опаснее, чем кажется. Она покрывает достаточно, чтобы тесты выглядели надёжными, и расходится ровно там, где начинается специфика: типы, индексы, блокировки. Расхождение всплывает не в тесте, а на проде.
// Формулировки: «зачем Testcontainers, если есть быстрая H2?», «какая должна быть пирамида тестов?», «как тестировать обращения к внешнему API?», «как изолировать данные между тестами?».
Пирамида тестов
Форма набора тестов важнее их количества. В основании много быстрых модульных тестов - миллисекунды, никакой обстановки, проверяют логику. Посередине меньше интеграционных: настоящая база, настоящий веб-слой, секунды. Наверху совсем немного сквозных, проверяющих главные пользовательские сценарии целиком.
Перевёрнутая форма - частая беда: сквозных тестов много, модульных мало. Прогон занимает часы, падения не объясняют причину, а исправление одной ошибки ломает пять других тестов.
// Критерий, куда положить тест, простой: что именно ты хочешь поймать. Ошибку в вычислении - модульным. Неверный запрос, кривая схема, нарушенное ограничение - интеграционным на настоящей базе. Разошедшийся контракт между сервисами - отдельным тестом контракта. Один и тот же случай не нужно проверять на всех уровнях.
- пирамида тестов
- много быстрых модульных, меньше интеграционных, мало сквозных
Настоящая зависимость в контейнере
Testcontainers поднимает настоящую зависимость в контейнере Docker на время теста: ту самую версию PostgreSQL, что стоит в проде, тот же брокер, тот же кэш. Адрес и порт библиотека подставляет в настройки приложения сама, потому что порт выбирается свободный.
Что это даёт по сравнению с базой в памяти, видно из моего замера: типы вроде JSONB, массивы, специфические индексы, поведение блокировок и уровней изоляции воспроизводятся честно. Заодно проверяются миграции схемы - на пустом контейнере они прогоняются с нуля, и ошибка в миграции обнаруживается до выката.
// Цена - время старта контейнера, и её снижают повторным использованием: контейнер поднимается один раз на весь прогон, а не на каждый класс. Тогда секунды платятся однократно.
- Testcontainers
- настоящая зависимость в Docker-контейнере на время теста
Внешние сервисы и изоляция данных
Чужой сервис в тестах не дёргают: он бывает недоступен, отвечает по-разному и не терпит нагрузки. Вместо этого поднимают подставной HTTP-сервер, который отвечает заранее записанным. Так проверяется и разбор ответа, и поведение при ошибке, и таймаут - последнее особенно важно, потому что в жизни отказ соседа именно так и выглядит.
Между тестами данные надо разводить, иначе они начинают зависеть от порядка. Три рабочих способа. Транзакция с откатом - быстро, но прячет часть проблем. Очистка таблиц перед тестом - честно и предсказуемо. Уникальные данные в каждом тесте - когда чистить дорого.
// Худший вариант - общая база, наполненная один раз на всех, с тестами, которые правят её по очереди. Такой набор работает ровно до первого параллельного запуска, а потом начинает падать через раз, и разбираться в этом мучительно.
- подставной HTTP-сервер
- отвечает заранее записанным вместо настоящего чужого сервиса
Как отвечать: «Зачем Testcontainers, если есть быстрая H2?»
Потому что H2 - другая база, и расходится она в самых неприятных местах. Я проверял на семи запросах в диалекте PostgreSQL: пять H2 переварила, а на массивах и типе JSONB упала. И это ещё безобидный случай, потому что ошибка видна сразу. Хуже, когда поведение просто отличается: индексы, уровни изоляции, блокировки, точность типов, порядок без явной сортировки. Тест зелёный, а в проде другой план запроса или взаимная блокировка двух транзакций, которой на H2 просто не случалось. Testcontainers поднимает ту же версию PostgreSQL, что стоит в проде, и заодно прогоняет с нуля миграции - скрипты, которые приводят схему базы к нужному виду. Плата - время старта контейнера, и я его плачу один раз на весь прогон, переиспользуя контейнер между классами. Логику при этом продолжаю проверять быстрыми модульными тестами - на настоящей базе я проверяю то, ради чего она и нужна.
Почему это сильный ответ: разница показана конкретными примерами, назван более опасный класс расхождений - тихо отличающееся поведение, - и разведены зоны ответственности видов тестов.
На чём валят
- −Считать H2 заменой PostgreSQL. У меня она проглотила пять запросов из семи и упала на массивах и JSONB - расхождение всплывёт на проде.
- −Поднимать контейнер на каждый тестовый класс. Секунды старта умножаются на число классов.
- −Ходить в чужой сервис из тестов. Он недоступен, отвечает по-разному и не терпит нагрузки; для этого есть подставной сервер.
- −Общая база на все тесты без изоляции. Начинает падать через раз при параллельном запуске.
- −Строить перевёрнутую пирамиду. Прогон на часы, а падение не объясняет причину.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 14, остальные разбираются в тренажёре.
- Почему тесты репозитория на H2 могут врать про прод на Postgres?A)H2 работает заметно медленнее прочих, и тесты отваливаются по таймаутуB)Диалект, типы и поведение SQL у H2 и Postgres отличаютсяC)H2 не поддерживает транзакции, в отличие от PostgresD)H2 хранит данные на диске, а Postgres — в памяти
показать ответ и разбор
+B)Диалект, типы и поведение SQL у H2 и Postgres отличаются// разбор: H2 — отдельная СУБД со своим диалектом: приведение типов, оконные функции, upsert, трактовка constraint'ов и синтаксис кое-где расходятся с Postgres. Запрос, зелёный на H2, в проде может падать или считать иначе. H2 быстра и удобна для смоук-проверок, но для верности репозиторного слоя берут реальный движок через Testcontainers.
- Тестируем HTTP-клиент, дёргающий чужой платёжный API. Как проверить обработку ответов без реального сервиса?A)Замокать сам HttpClient и возвращать заранее готовую строку ответаB)Дёргать боевой API платёжки с тестовым ключомC)Поднять WireMock/MockWebServer с заданными ответамиD)Отключить сеть и ловить исключения соединения
показать ответ и разбор
+C)Поднять WireMock/MockWebServer с заданными ответами// разбор: WireMock (или MockWebServer) поднимает фейковый HTTP-сервер с запрограммированными ответами: 200 с телом, 500, таймаут, кривой JSON. Клиент реально ходит по HTTP — проверяется настоящая сериализация, парсинг, обработка кодов и таймаутов. Мок HttpClient пропускает этот слой, а бить по боевому API в тестах нельзя.
- Контейнер Postgres стартует на случайном порту. Как отдать его адрес в Spring-контекст теста?A)Захардкодить localhost:5432 в application-test.ymlB)Никак — Spring сам находит поднятый контейнер по имени его Docker-образаC)Прописать порт вручную после старта в @BeforeEachD)Через @DynamicPropertySource прокинуть jdbc-url контейнера в окружение
показать ответ и разбор
+D)Через @DynamicPropertySource прокинуть jdbc-url контейнера в окружение// разбор: Testcontainers маппит порт СУБД на случайный хостовый — фиксированный 5432 не подойдёт. @DynamicPropertySource — статический метод, который РЕГИСТрирует spring.datasource.url/username/password из уже поднятого контейнера до создания контекста. Хардкод порта хрупок, ручная правка в @BeforeEach опаздывает (контекст уже собран), автопоиска по имени образа нет.
- Контейнер объявлен static на класс, а не как поле экземпляра. Что это меняет?A)Контейнер поднимается один раз на класс и переиспользуется — прогон быстрееB)Контейнер стартует заново перед каждым тест-методом — это максимальная изоляцияC)Ничего: Testcontainers поднимает ровно один контейнер на JVMD)static-контейнер не очищается между тестами
показать ответ и разбор
+A)Контейнер поднимается один раз на класс и переиспользуется — прогон быстрее// разбор: static-контейнер живёт на весь класс: поднялся раз, все тесты используют его — заметно быстрее, чем нестатический, что стартует свежий на каждый метод (сильнее изоляция, но дороже). При переиспользовании тесты обязаны сами приводить данные в известное состояние, иначе один протечёт в другой. Очищать состояние static-контейнеру ничто не мешает.
- Почему интеграционные тесты обычно выносят в отдельную стадию CI, а не гоняют на каждый коммит?A)Они склонны к флаки, и их прячут подальшеB)Они медленнее и требуют Docker — держат быструю обратную связь на юнитахC)JUnit не умеет запускать их вместе с юнитамиD)Интеграционные тесты не дают полезного сигнала
показать ответ и разбор
+B)Они медленнее и требуют Docker — держат быструю обратную связь на юнитах// разбор: Юниты быстрые и без внешних зависимостей — их гоняют на каждый коммит ради мгновенной обратной связи. Интеграционные поднимают контейнеры/БД, идут дольше и требуют Docker в раннере, поэтому их часто метят @Tag и запускают отдельной стадией или по расписанию. Не потому что бесполезны или флаки — а чтобы не тормозить каждый push.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.