← назад к разделу

Модульные тесты проверяют логику в изоляции, но не говорят, правильно ли приложение работает с базой данных, кешем или внешними сервисами. Для этого нужны интеграционные тесты — с реальными зависимостями.

модульный тест кончается там, где появляется настоящая база HTTP Контроллер Сервис заглушка модульный тест интеграционный тест Репозиторий PostgreSQLDocker-контейнер POST /orders SQL не выполняется PostgreSQL что проверил этот тест настоящий SQL дошёл до базы колонки легли в поля объекта UNIQUE не дал вставить дубль цена настоящей базыстарт ~3 содин раз на весь прогондальше — миллисекунды на запрос

Модульный тест держит в рамке один сервис: репозиторий подменён, SQL не выполняется. Как только вместо заглушки встаёт настоящий PostgreSQL в контейнере, рамка растягивается на всю цепочку — это уже интеграционный тест. Он проверяет ровно то, чего не видит заглушка: сам запрос, раскладку колонок по полям объекта и ограничения базы.

Обязательно

Проблема H2 и моков

Самый простой способ протестировать репозиторий — подключить встроенную базу данных H2. Это быстро, не требует Docker, тесты запускаются везде. Но у подхода есть серьёзный изъян: H2 — не PostgreSQL.

Различия накапливаются незаметно: синтаксис SQL-функций, поведение при конфликтах уникальности, работа JSON-типов, оконные функции. Тест проходит на H2, а на продакшне падает — потому что диалекты отличаются.

То же с моками базы данных: мок проверяет, что метод был вызван с нужными аргументами, но не проверяет сам SQL-запрос, маппинг результата и поведение транзакции. Интеграционный тест поднимает тот же PostgreSQL, что работает в продакшне, и убирает целый класс ошибок.

@SpringBootTest: полный контекст

@SpringBootTest поднимает весь контекст приложения — точно так же, как при запуске. Это наиболее тяжёлый вариант теста, зато самый близкий к реальной работе.

@SpringBootTest
@AutoConfigureMockMvc
class OrderApiTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void createsOrder() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"productId": "abc", "quantity": 2}
                        """))
            .andExpect(status().isCreated());
    }
}

По умолчанию сервер не стартует — вместо этого используется MockMvc. Если нужен реальный HTTP-порт, добавьте webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT.

Полный контекст оправдан, когда важно проверить сквозной путь: HTTP → сервис → база данных → ответ. Для изолированной проверки одного слоя есть срезы.

Срезы: @DataJpaTest и @WebMvcTest

Срез (slice) — это урезанный контекст Spring, в котором поднимается только нужный слой. Остальные бины не загружаются, поэтому срез стартует значительно быстрее полного контекста.

@WebMvcTest запрос фильтр Security контроллер заглушка сервиса @DataJpaTest тест репозиторий EntityManager встроенная база @SpringBootTest запрос контроллер сервис PostgreSQL

Один и тот же путь вызова в трёх контекстах: в срезе контроллера он упирается в заглушку и проходит через фильтр Security, в срезе репозитория веба нет вовсе, и только полный контекст доводит запрос до настоящей базы.

@DataJpaTest поднимает только слой работы с данными: репозитории, EntityManager, транзакции. По умолчанию он подменяет datasource приложения на встроенную базу — обычно H2, если она лежит в тестовых зависимостях.

Подставить вместо неё контейнер с PostgreSQL можно, но здесь есть ступенька, о которую спотыкаются почти все. Подменой заведует @AutoConfigureTestDatabase, и до Spring Boot 3.4 он срабатывал всегда: контейнер честно стартовал, а тест всё равно уходил на H2 — спасал только явный @AutoConfigureTestDatabase(replace = Replace.NONE). С 3.4 режим по умолчанию NON_TEST: datasource, пришедший от @ServiceConnection, @DynamicPropertySource или URL вида jdbc:tc:, больше не подменяется, и дописывать ничего не нужно.

@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    ProductRepository repository;

    @Test
    void findsActiveProducts() {
        var saved = repository.save(new Product("Widget", true));
        var found = repository.findAllActive();
        assertThat(found).contains(saved);
    }
}

@WebMvcTest поднимает только слой контроллеров: @Controller, @ControllerAdvice, фильтры, MockMvc. Сервисы и репозитории в этот контекст не входят — их нужно мокировать через @MockitoBean.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    OrderService orderService;

    @Test
    void returnsBadRequestOnMissingBody() throws Exception {
        mockMvc.perform(post("/orders"))
            .andExpect(status().isBadRequest());
    }
}

Оговорка, из-за которой этот пример у многих не повторяется: срез тянет за собой и автоконфигурацию Spring Security вместе с её фильтрами. В проекте с spring-boot-starter-security тот же post("/orders") вернёт не 400, а 403 — не хватило CSRF-токена (или 401, если запрос не аутентифицирован). Чтобы проверять именно контроллер, запрос подписывают: .with(csrf()) на запросе и @WithMockUser на тесте.

Testcontainers: реальный PostgreSQL в Docker

Testcontainers — это Java-библиотека, которая запускает Docker-контейнеры прямо из кода теста. Контейнер стартует перед тестовым классом и останавливается после него — а при желании живёт и дольше, об этом ниже. Никакой настройки окружения вручную — достаточно установленного Docker.

// build.gradle.kts
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")

Третья строка нужна не для красоты: аннотации @Testcontainers и @Container из примеров ниже живут именно в junit-jupiter. spring-boot-testcontainers даёт @ServiceConnection, но связку с JUnit не приносит — без неё оба листинга просто не соберутся.

@SpringBootTest
@Testcontainers
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    OrderRepository repository;

    @Test
    void savesAndReadsOrder() {
        var order = repository.save(new Order(UUID.randomUUID(), "PENDING"));
        assertThat(repository.findById(order.id())).isPresent();
    }
}

@Container + static означает, что контейнер создаётся один раз на класс. @DynamicPropertySource передаёт URL, логин и пароль контейнера в контекст Spring до его старта.

Работают эти аннотации только в паре: запускает контейнеры расширение JUnit, которое включает @Testcontainers на классе. Забыли её — контейнер в поле просто не стартует, и тест упадёт на попытке достать у него порт. Чаще всего так и происходит при выносе контейнера в базовый класс: аннотацию оставляют на старом месте.

Чтобы не платить временем, контейнер поднимают один раз на весь прогон: статическое поле в базовом тестовом классе (контейнер живёт, пока живёт JVM) или переиспользуемый контейнер (withReuse(true) с testcontainers.reuse.enable=true в ~/.testcontainers.properties) — он переживает и перезапуски тестов на машине разработчика.

Схема накатывается миграциями один раз, а тесты чистят за собой данные или выполняются в откатываемой транзакции. Старт — секунды, и платят их один раз.

Запросы к PostgreSQL в контейнере всё же дороже, чем к H2: та живёт внутри той же JVM, а здесь каждый запрос идёт через сокет к отдельному процессу, и на тесте из сотни мелких запросов это видно. Но рядом со стартом контейнера разница теряется — и окупается тем, что тест проверяет настоящий SQL, а не его пересказ на диалекте H2.

Откуда в пустом контейнере таблицы

Контейнер поднимается с пустой базой: в образе postgres:16-alpine нет ни одной вашей таблицы. Вопрос, который здесь возникает у каждого и почти нигде не отвечен: кто их создаёт?

Ответ: те же миграции, что и в проде. Инструмент миграций (Liquibase или Flyway) — часть приложения, и при старте контекста Spring Boot запускает его до всего остального. То есть ничего специально настраивать не нужно: контейнер стартовал, контекст поднялся, миграции накатились, тест пошёл. И побочная польза огромна: каждый интеграционный тест заодно проверяет, что миграции применяются на чистой базе.

А вот что нужно проверить в тестовом профиле — одну настройку, которая всё это тихо отменяет:

# src/test/resources/application.yml
spring:
  jpa:
    hibernate:
      ddl-auto: validate   # НЕ create и не create-drop
  flyway:
    enabled: true

ddl-auto: create — самая дорогая ловушка темы. С ней схему создаёт не миграция, а сам Hibernate, по вашим сущностям: таблицы появляются, тесты зелёные, миграции не запускаются вовсе. Расхождение между сущностью и миграцией становится невидимым до прода, где схему создавали миграции, и первый же запрос падает на отсутствующей колонке. С validate наоборот: Hibernate сверяет сущности с тем, что создали миграции, и тест не стартует при расхождении — это единственная бесплатная проверка соответствия кода и схемы до выката.

Отдельно: справочные данные (статусы, типы, тарифы), которые в проде вставляет миграция, появятся в тестовой базе сами и должны там остаться. Это важно помнить, когда дойдёте до очистки данных между тестами: если вычистить всё подряд, следующий тест не найдёт справочника, который в проде есть всегда.

Как проверять сами миграции всерьёз — на непустой базе, со временем и блокировками — разбирает раздел «Глубже» ниже.

Изоляция между тестами при одном контейнере

Контейнер один на весь прогон — значит, база одна на все тесты. Первый тест создал заказ, второй посчитал заказы и получил не ту цифру; третий проходит один и падает в наборе. Фраза «тесты чистят за собой» отвечает только «что», и стоит разобрать «как», потому что у трёх способов разные границы.

Откат транзакции. @Transactional на тестовом классе: Spring открывает транзакцию перед тестом и откатывает после, база остаётся чистой. Быстро, ничего не надо писать, и это выбор по умолчанию для тестов репозиториев. Ограничений три, и все заметные:

  • Не проверяет поведение после фиксации. Отложенные ограничения, срабатывания на фиксации, всё, что происходит в момент COMMIT, в таком тесте не случается вовсе. Тест зелёный, прод падает на нарушении уникальности.
  • Не работает через настоящий HTTP. Тест с реальным портом (RANDOM_PORT и TestRestTemplate) обрабатывает запрос в другом потоке, со своей транзакцией: откат тестовой транзакции к этим данным отношения не имеет, и они остаются в базе.
  • Прячет ошибки сохранения. Пока транзакция не зафиксирована, save() может вообще не дойти до базы, потому что запись отложена до сброса. Тест, который «сохранил и прочитал», читает из памяти, а не из базы. Лечится принудительным сбросом и очисткой перед проверкой.

Очистка таблиц после теста. Честный способ для всего, что проверяет поведение после фиксации: тест пишет по-настоящему, а уборщик возвращает базу в исходное состояние. Наивный вариант — repository.deleteAll() в @AfterEach — ломается сразу: порядок удаления важен из-за внешних ключей, о дочерних таблицах легко забыть, и это десятки запросов. Правильный вариант — один запрос на все таблицы:

@Component
public class DatabaseCleaner {

    private final JdbcTemplate jdbc;
    private List<String> tables;

    public DatabaseCleaner(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public void clean() {
        if (tables == null) {
            tables = jdbc.queryForList("""
                    SELECT quote_ident(tablename)
                    FROM pg_tables
                    WHERE schemaname = 'public'
                      AND tablename NOT IN ('flyway_schema_history', 'order_status_ref')
                    """, String.class);
        }
        jdbc.execute("TRUNCATE TABLE " + String.join(", ", tables)
                + " RESTART IDENTITY CASCADE");
    }
}

Что здесь важно по частям. TRUNCATE перечисляет все таблицы в одном запросе — тогда внешние ключи между ними не мешают, и CASCADE нужен лишь на случай ссылок извне списка. RESTART IDENTITY сбрасывает счётчики, иначе идентификаторы растут через весь прогон и тесты, ожидающие id = 1, ведут себя загадочно. Таблица истории миграций и справочники исключены — их наполняет миграция, и вычистить их означает сломать всё следующее. Список таблиц читается из базы один раз: новая таблица попадает в уборку сама, без правки кода.

Уникальные данные вместо очистки. Третий способ: ничего не удалять, а делать каждый тест независимым по данным — свой идентификатор, свой номер заказа, свой адрес почты. Тогда порядок тестов не важен в принципе, а прогон можно смело распараллеливать: тесты друг друга не видят. Это единственный способ, который выживает при параллельном прогоне в несколько потоков на одной базе. Цена: любая проверка «а сколько всего записей» становится неверной, поэтому считать надо всегда с условием по своему ключу, а не count() по таблице.

Как выбрать: репозиторий и запросы — откат транзакции; всё, что проходит через настоящий HTTP или проверяет поведение после фиксации — очистка таблиц; параллельный прогон — уникальные данные. Смешивать первое и второе в одном классе не стоит: тест с @Transactional внутри класса с уборщиком создаёт путаницу, в которой непонятно, откуда взялись или куда пропали данные.

@ServiceConnection: без ручной настройки URL

Spring Boot 3.1 ввёл @ServiceConnection — аннотацию, которая убирает шаблонный @DynamicPropertySource. Spring сам распознаёт тип контейнера и настраивает нужные свойства.

@SpringBootTest
@Testcontainers
class OrderRepositorySharedContainerTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    // @DynamicPropertySource больше не нужен

    @Autowired
    OrderRepository repository;
}

Без неё три строки registry.add(...) пишут руками в каждом тестовом классе, и стоит контейнеру сменить имя свойства или порт, как они тихо расходятся. @ServiceConnection убирает это: Spring Boot сам настраивает datasource, Redis, RabbitMQ и другие поддерживаемые типы контейнеров по самому контейнеру.

Для Redis это работает точно так же:

@Container
@ServiceConnection
static GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

Здесь стоит остановиться, потому что слово «поддерживаемый тип» решает, заработает ваш пример или нет. Spring Boot определяет, какие свойства настраивать, двумя путями. Первый — по классу контейнера: увидел PostgreSQLContainer, MongoDBContainer, KafkaContainer — и знает, что это за служба. Второй — по имени образа: у GenericContainer класс ничего не говорит, поэтому в дело идёт образ, и redis:7-alpine распознаётся именно так, по имени redis.

Отсюда понятно, когда это ломается: свой образ с непонятным именем (registry.company.ru/infra/cache-redis:3) не распознаётся ни по классу, ни по имени, и @ServiceConnection молча ничего не настроит. Тогда имя службы указывают руками:

@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> cache =
        new GenericContainer<>("registry.company.ru/infra/cache-redis:3")
                .withExposedPorts(6379);

Признак, что вы попали в эту ловушку: контейнер поднялся, а приложение ходит на localhost:6379 мимо него — то есть на адрес из application.yml, потому что настройку никто не переопределил.

Не только PostgreSQL: Kafka, свои образы, один контейнер на прогон

Testcontainers — это не библиотека про базу, а библиотека про «подними мне что угодно из образа и дай адрес». Три сценария, которые нужны почти всем.

Брокер. Тест, проверяющий, что событие ушло и слушатель его разобрал, поднимает настоящий Kafka:

@SpringBootTest
@Testcontainers
class OrderEventsTest {

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

    @Autowired
    OrderService orders;

    @Test
    void publishesOrderCreated() {
        orders.place(orderRequest());

        await().atMost(Duration.ofSeconds(10))
                .untilAsserted(() -> assertThat(received).hasSize(1));
    }
}

Два замечания к этому примеру. Первое: @ServiceConnection сам подставит адреса брокера, руками их прописывать не надо. Второе: проверка обязательно с ожиданием, а не сразу после вызова — сообщение доходит до слушателя асинхронно, и assertThat следующей строкой будет мигать. Kafka в контейнере стартует медленнее базы (секунды), поэтому её держат на весь прогон, а не на класс.

Что угодно из образа. Нет специального класса — берут GenericContainer: заглушка внешнего партнёра, хранилище объектов, совместимое с S3, почтовый ловец, другая база. Адрес и порт спрашивают у контейнера (getHost(), getMappedPort(...)) и передают в контекст через @DynamicPropertySource. Порт всегда спрашивают у контейнера, а не пишут напрямую: изнутри контейнера он свой, а снаружи случайный.

Один контейнер на весь прогон. Самый практичный рецепт, и он проще, чем кажется: убрать @Container и стартовать контейнер в статическом блоке базового класса.

public abstract class IntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine");

    static {
        POSTGRES.start();
    }

    @DynamicPropertySource
    static void datasource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);
    }
}

Почему так, а не @Container static: аннотация привязывает жизнь контейнера к классу тестов, и при двадцати классах вы получаете двадцать стартов. Статический блок выполняется один раз на загрузку класса, то есть один раз на всю виртуальную машину прогона, и контейнер живёт до её конца. Останавливать его не нужно: за уборкой следит сторожевой контейнер Testcontainers, который гасит всё после завершения процесса. Этот же приём — единственный работающий, когда прогон идёт в несколько форков: у каждого форка своя виртуальная машина и свой контейнер, и тесты не делят базу.

И противоположный случай, о котором стоит знать: withReuse(true) оставляет контейнер жить между прогонами, чтобы не платить стартом на каждый запуск тестов локально. Работает он только при testcontainers.reuse.enable=true в файле ~/.testcontainers.properties на машине, а в сборке этот флаг обычно выключен — там контейнер поднимается заново. Из этого следует неочевидное требование: переиспользуемый контейнер сохраняет данные между прогонами, поэтому тесты обязаны быть чистыми сами по себе. Тест, который проходит на свежей базе и падает на второй запуск, — это не поломка переиспользования, а найденная зависимость от пустой базы, и лучше узнать о ней локально, чем на красной сборке.

Цена входа: тестам нужен Docker

Про Testcontainers обычно говорят «достаточно установленного Docker», и это правда, за которой стоит несколько решений, принимаемых один раз на команду. Лучше знать их заранее, чем обнаружить на красной сборке.

Агент сборки. Тестам нужен доступ к демону Docker, а агент сборки сам обычно работает в контейнере. Варианты: смонтировать в агент сокет демона хоста (просто, но даёт тестам права хоста), поднять отдельный демон рядом, или указать DOCKER_HOST на удалённый демон — тогда контейнеры поднимаются на выделенной машине, а тесты ходят к ней по сети. Для облачных агентов у всех крупных систем сборки Docker доступен из коробки, и проблема касается в основном своих установок.

Машина разработчика. Не у всех есть Docker Desktop, а в больших компаниях он платный — это не мелочь, а строка в бюджете на каждого разработчика. Заменители работают: Colima и Rancher Desktop на macOS, Podman с включённым сокетом, совместимым с Docker. Testcontainers ищет демон по стандартным путям и обычно находит их сам; если нет — путь задаётся переменной окружения. И отдельно стоит решить, что делать с теми, у кого Docker нет вовсе: пометить интеграционные тесты меткой (@Tag("docker")) и исключить их из локального прогона по умолчанию, но не из сборки. Вариант «у нас часть команды гоняет только модульные» рабочий; вариант «мы не включили эти тесты в сборку, потому что у кого-то нет Docker» — нет.

Закрытый контур. Если выхода в интернет нет, образы не скачаются. Решение — внутреннее зеркало реестра и настройка, которая дописывает его префикс ко всем образам (testcontainers.hub.image.name.prefix в файле свойств или переменная окружения), чтобы не править каждый тест. В зеркало кладут и служебный образ-сторож, которым Testcontainers убирает за собой; забыть его — типичная причина «всё скачалось, а тесты всё равно не стартуют».

Время и ресурсы. Каждый контейнер — это память и секунды старта: база несколько секунд, брокер до десяти, набор из четырёх служб превращает «быстрый локальный прогон» в минуту ожидания. Поэтому контейнеры поднимают один раз на прогон (см. выше), а не на класс, и не поднимают то, что в этом тесте не нужно.

Вывод не «Testcontainers дорог», а «это инфраструктурное решение, а не просто библиотека»: оно требует одного разговора с теми, кто держит сборку, и после этого работает годами.

Когда полный контекст, а когда срез

СитуацияЧто использовать
Сквозной тест HTTP → база → ответ@SpringBootTest + Testcontainers
Проверить SQL-запросы репозитория@DataJpaTest + @ServiceConnection (на Boot до 3.4 — ещё и replace = Replace.NONE)
Проверить валидацию и ответы контроллера@WebMvcTest + @MockitoBean
Проверить бизнес-логику сервисаМодульный тест, без Spring-контекста

Правило: поднимать ровно столько контекста, сколько нужно. Лишние бины замедляют старт и могут вносить неожиданные зависимости. Полный @SpringBootTest оправдан только для сквозных сценариев.

Если тестов с Testcontainers много, контейнер стоит выносить в общий базовый класс с @Container static — тогда Docker-образ поднимается один раз для всего набора тестов, а не отдельно под каждый класс.

И когда после этого прогон всё равно растёт, причина почти никогда не в контейнерах. Дело в кеше контекста Spring: контекст поднимается один раз на уникальный набор настроек и переиспользуется всеми тестами с тем же набором. Ключ кеша складывается из всего, что меняет конфигурацию: классы конфигурации, активные профили, свойства из @TestPropertySource, набор подменённых бинов. Добавили в один тест @MockitoBean — у него другой набор бинов, значит другой ключ, значит ещё один полный контекст, ещё раз миграции, ещё раз пулы соединений. Двадцать тестовых классов с чуть разными наборами дают двадцать контекстов и прогон в десятки минут при мгновенных тестах.

Поэтому смысл общего базового класса не в контейнере, а в единстве ключа: все тесты наследуют одну и ту же конфигурацию, один и тот же список свойств и один и тот же набор подмен — и поднимают один контекст на всех. Практически это значит: настройки и контейнеры только в базовом классе, подмены бинов не по одному тесту, а вынесены в общую тестовую конфигурацию, и число разных базовых классов держится в единицах. Проверить, сколько контекстов поднялось, можно включив отладочный вывод кеша контекстов — в журнале прогона видно каждое создание и каждое попадание.

Дополнительно: при первом чтении можно пропустить

Глубже: мигающие тесты: общее состояние, порядок, паузы, порты и параллельный прогонрасширенное

Тест, который то зелёный, то красный, разрушает доверие к сборке быстрее, чем отсутствие тестов: через месяц любую красноту перезапускают не глядя, и настоящую поломку перезапускают вместе с ней. Причин у мигания немного, и каждая находится.

Общее состояние. Статическое поле, кеш, синглтон, строка в базе, которую оставил предыдущий тест. Тест зависит от того, что было до него, и падает при другом порядке. Находят запуском в случайном порядке (junit.jupiter.testmethod.order.default со случайным упорядочивателем) и по одному: тест, который проходит один и падает в наборе, зависит от соседей. Лечат изоляцией: каждый тест готовит своё и убирает своё, а данные не удаляют между тестами, а различают по уникальному ключу, о чём раздел про изоляцию данных в статье про моки.

Паузы вместо ожиданий. Thread.sleep(500) перед проверкой асинхронного результата проходит на ноутбуке и падает на загруженном раннере. Ждут условия, а не времени: Awaitility с atMost и untilAsserted опрашивает проверку, пока она не пройдёт, и падает только по истечении предела; как это устроено для @Async и слушателей очереди, разбирает статья про тестирование в Spring.

Время и случайность. Тест, зависящий от «сейчас», падает в полночь, в конце месяца и при смене часового пояса раннера; случайные данные без зафиксированного зерна дают невоспроизводимое падение. Clock внедряют и подменяют, случайность фиксируют зерном и печатают его при падении.

Порты и ресурсы. Два теста поднимают сервер на одном порту, два прогона делят один контейнер базы. Порт всегда 0 (случайный свободный), контейнер на класс тестов или на прогон, а при параллельных форках Gradle у каждого свой контейнер или своя схема в базе, о чём статья про CI для Java.

Параллельный прогон обнажает всё перечисленное сразу, поэтому его включают не «для скорости», а как проверку изоляции, и включают постепенно: по классам, потом по методам.

Процесс. Мигающий тест не перезапускают до зелёного, а помечают (@Tag("flaky")) и выводят из обязательных проверок в карантин с задачей на починку и сроком; сборка считает долю перезапусков, и рост этой доли это тревога. Аннотации автоматического повтора существуют и годятся для тестов, зависящих от внешней сети, но не для своих: они прячут причину.

Глубже: тест миграций: накатить на непустую базу и почему ddl-auto прячет расхождениерасширенное

Статья дважды говорит, что схему накатывают миграции, и ни разу не показывает, как это проверить. Проверок три, и первая уже есть в любом тесте на Testcontainers, если не мешать.

Миграции проходят с нуля. Spring Boot при старте контекста запускает Liquibase или Flyway на чистой базе контейнера, и любой интеграционный тест это проверяет заодно. Ломают это настройкой spring.jpa.hibernate.ddl-auto=create в тестовом профиле: Hibernate создаёт схему по сущностям, миграции не запускаются, тесты зелёные, а в проде первая же миграция или расхождение колонки роняет старт. В тестах стоит ddl-auto: validate, тот же, что в проде: тогда тест падает, если сущность разошлась с тем, что создали миграции, и это единственная проверка соответствия кода и схемы до выката.

Миграция на непустой базе. Пустая таблица не покажет, что ALTER TABLE держит блокировку минуту, что NOT NULL на колонку с существующими NULL невозможен, что перелив данных не идемпотентен. Отдельный тест или шаг конвейера восстанавливает снимок схемы прода с обезличенным образцом данных (или заполняет таблицы генератором до реалистичного размера) и применяет только новые миграции с lock_timeout, замеряя время. Снимок обновляют регулярно, тест запускают на PR, где менялся каталог миграций.

Старый код на новой схеме. Во время выката и после отката предыдущая версия приложения работает с новой схемой, поэтому тесты предыдущей версии (образ с прошлым тегом уже есть) прогоняют против базы с новыми миграциями. Зелёный прогон означает, что миграция совместима, и откат безопасен; красный означает ломающее изменение, которое раскладывают на шаги, о чём статья про эволюцию схемы.

Что тесты не проверят: миграцию, которая зависит от данных, которых нет в снимке, и таймауты на таблицах в миллиард строк, которые больше любого стенда. Для них остаётся CONCURRENTLY, пакетный перелив и репетиция на копии, о чём разделы про PostgreSQL.

Глубже: тест доступа в срезе: кто получит 403, кто 200расширенное

Срез @WebMvcTest поднимает и Spring Security, поэтому запрос без пользователя получает 401, а тест, который об этом не знает, проваливается на первом же GET. Это не помеха, а возможность: доступ проверяется тем же срезом, что и контроллер.

Подложить пользователя можно тремя способами, и они проверяют разное. @WithMockUser(roles = "ADMIN") создаёт аутентификацию с указанными ролями напрямую и годится для проверки правил по ролям, но обходит разбор токена. jwt() из spring-security-test кладёт в контекст готовый JWT с нужными claim и правами и проверяет конвертер ролей и правила по claim. Подмена JwtDecoder заглушкой пропускает через настоящий фильтр заголовок Authorization с собранным в тесте токеном и проверяет aud, iss и истечение. Обязательный набор на закрытый эндпоинт: без пользователя 401, чужая роль 403, своя 200; для ресурсов с владельцем добавляют «чужой заказ по идентификатору отвечает 404».

CSRF в срезе включён, если он включён в приложении: POST без токена CSRF даёт 403 даже с правильным пользователем, и для API с JWT это означает, что в конфигурации CSRF должен быть выключен осознанно, а тест это подтверждает. Для приложений с сессией и формами добавляют .with(csrf()) к изменяющим запросам. Полный разбор с обходом всех маршрутов и denyAll по умолчанию в статье про Spring Security.

Коротко

  • H2 и моки баз данных скрывают ошибки, которые видны только с реальным PostgreSQL. Testcontainers запускает Docker-контейнер прямо из теста — тот же образ, что в продакшне.
  • @SpringBootTest поднимает весь контекст; срезы @DataJpaTest и @WebMvcTest — только нужный слой. Полный контекст берут ради сквозного пути от запроса до базы и обратно, остальное дешевле проверить срезом.
  • @ServiceConnection (Spring Boot 3.1+) убирает @DynamicPropertySource и сам настраивает datasource.
  • Общий базовый класс с static контейнером сокращает время сборки при большом числе тестов.
  • Мигание лечат по причинам: случайный порядок находит общее состояние, Awaitility вместо пауз, Clock и зерно вместо «сейчас» и случайности, порт 0 и контейнер на прогон; мигающий тест в карантин с задачей, а не на перезапуск.
  • Миграции проверяют трижды: с нуля при старте контекста с ddl-auto: validate, на снимке схемы прода с данными и временем, и тестами предыдущей версии против новой схемы.
  • Доступ проверяют в срезе: @WithMockUser для ролей, jwt() для claim, заглушка JwtDecoder для aud и истечения; на закрытый эндпоинт 401, 403, 200 и 404 для чужого; CSRF в срезе включён, если включён в приложении.
  • Таблицы в контейнере создают миграции при старте контекста, поэтому в тестах ddl-auto: validate, а не create: иначе схему делает Hibernate, миграции не проверяются, а справочные данные и история миграций при очистке не трогаются.
  • Изоляция: откат транзакции для репозиториев, TRUNCATE всех таблиц одним запросом с RESTART IDENTITY CASCADE для тестов через настоящий HTTP и поведения после фиксации, уникальные данные для параллельного прогона.
  • Контейнер поднимают один раз на прогон статическим блоком базового класса, а прогон растёт не от контейнеров, а от числа контекстов Spring: единый ключ кеша (одна конфигурация, одни свойства, одни подмены) важнее всего остального.

Что почитать дальше