сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Тестирование на Java

Интеграционные тесты и 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, остальные разбираются в тренажёре.

  1. #integration_testcontainers1 / 5
    Почему тесты репозитория на H2 могут врать про прод на Postgres?
    A)H2 работает заметно медленнее прочих, и тесты отваливаются по таймауту
    B)Диалект, типы и поведение SQL у H2 и Postgres отличаются
    C)H2 не поддерживает транзакции, в отличие от Postgres
    D)H2 хранит данные на диске, а Postgres — в памяти
    показать ответ и разбор
    +B)Диалект, типы и поведение SQL у H2 и Postgres отличаются

    // разбор: H2 — отдельная СУБД со своим диалектом: приведение типов, оконные функции, upsert, трактовка constraint'ов и синтаксис кое-где расходятся с Postgres. Запрос, зелёный на H2, в проде может падать или считать иначе. H2 быстра и удобна для смоук-проверок, но для верности репозиторного слоя берут реальный движок через Testcontainers.

  2. #integration_testcontainers2 / 5
    Тестируем HTTP-клиент, дёргающий чужой платёжный API. Как проверить обработку ответов без реального сервиса?
    A)Замокать сам HttpClient и возвращать заранее готовую строку ответа
    B)Дёргать боевой API платёжки с тестовым ключом
    C)Поднять WireMock/MockWebServer с заданными ответами
    D)Отключить сеть и ловить исключения соединения
    показать ответ и разбор
    +C)Поднять WireMock/MockWebServer с заданными ответами

    // разбор: WireMock (или MockWebServer) поднимает фейковый HTTP-сервер с запрограммированными ответами: 200 с телом, 500, таймаут, кривой JSON. Клиент реально ходит по HTTP — проверяется настоящая сериализация, парсинг, обработка кодов и таймаутов. Мок HttpClient пропускает этот слой, а бить по боевому API в тестах нельзя.

  3. #integration_testcontainers3 / 5
    Контейнер Postgres стартует на случайном порту. Как отдать его адрес в Spring-контекст теста?
    A)Захардкодить localhost:5432 в application-test.yml
    B)Никак — Spring сам находит поднятый контейнер по имени его Docker-образа
    C)Прописать порт вручную после старта в @BeforeEach
    D)Через @DynamicPropertySource прокинуть jdbc-url контейнера в окружение
    показать ответ и разбор
    +D)Через @DynamicPropertySource прокинуть jdbc-url контейнера в окружение

    // разбор: Testcontainers маппит порт СУБД на случайный хостовый — фиксированный 5432 не подойдёт. @DynamicPropertySource — статический метод, который РЕГИСТрирует spring.datasource.url/username/password из уже поднятого контейнера до создания контекста. Хардкод порта хрупок, ручная правка в @BeforeEach опаздывает (контекст уже собран), автопоиска по имени образа нет.

  4. #integration_testcontainers4 / 5
    Контейнер объявлен static на класс, а не как поле экземпляра. Что это меняет?
    A)Контейнер поднимается один раз на класс и переиспользуется — прогон быстрее
    B)Контейнер стартует заново перед каждым тест-методом — это максимальная изоляция
    C)Ничего: Testcontainers поднимает ровно один контейнер на JVM
    D)static-контейнер не очищается между тестами
    показать ответ и разбор
    +A)Контейнер поднимается один раз на класс и переиспользуется — прогон быстрее

    // разбор: static-контейнер живёт на весь класс: поднялся раз, все тесты используют его — заметно быстрее, чем нестатический, что стартует свежий на каждый метод (сильнее изоляция, но дороже). При переиспользовании тесты обязаны сами приводить данные в известное состояние, иначе один протечёт в другой. Очищать состояние static-контейнеру ничто не мешает.

  5. #integration_testcontainers5 / 5
    Почему интеграционные тесты обычно выносят в отдельную стадию CI, а не гоняют на каждый коммит?
    A)Они склонны к флаки, и их прячут подальше
    B)Они медленнее и требуют Docker — держат быструю обратную связь на юнитах
    C)JUnit не умеет запускать их вместе с юнитами
    D)Интеграционные тесты не дают полезного сигнала
    показать ответ и разбор
    +B)Они медленнее и требуют Docker — держат быструю обратную связь на юнитах

    // разбор: Юниты быстрые и без внешних зависимостей — их гоняют на каждый коммит ради мгновенной обратной связи. Интеграционные поднимают контейнеры/БД, идут дольше и требуют Docker в раннере, поэтому их часто метят @Tag и запускают отдельной стадией или по расписанию. Не потому что бесполезны или флаки — а чтобы не тормозить каждый push.

дальше

Теорию прочитали. Навык ставится повторением

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