JavaRush /Курсы /Spring Test /Сильный container-based subset

Сильный container-based subset

Spring Test
25 уровень , 4 лекция
Открыта

1. Subset как стратегия

К этому месту уже есть две рабочие формы container-backed tests: узкий repository path и точечный full-context flow. Остался последний вопрос: какие из них вообще стоит оставить в suite, чтобы real PostgreSQL добавлял уверенность, а не просто замедлял запуск.

Container-based subset — это не “облегчённая версия качества”. Это способ выбрать несколько тестов с максимальным ROI и не превращать PostgreSQL path в новый дефолт для всего проекта. Если вы можете назвать каждый такой тест по имени и в одно предложение объяснить, какой DB-specific риск он ловит, subset собран правильно.

Чтобы закрепить эту мысль, представим картину как слои пирога (да, тестирование снова про еду; видимо, мы проголодались):

flowchart TD
    Unit["Unit tests (без Spring)
быстро, дешево, точно"] Slice["Slices (@WebMvcTest, @JsonTest, @DataJpaTest)
быстро, локально"] Integration["@SpringBootTest / live-server
дорого, но нужно"] Containers["Container-based subset
очень дорого, но даёт уверенность там, где надо"] Unit --> Slice --> Integration --> Containers

Container-based subset — это верхушка, а не основание. Он не должен держать весь дом, он должен закрыть те дырки, которые остались после всех остальных слоёв.

2. Четыре фильтра: риск, ценность, стабильность, цена

Когда мы выбираем, какие тесты достойны контейнера, полезно не пытаться сразу “угадать” правильный список, а прогнать кандидатов через несколько простых фильтров. Это похоже на собеседование: контейнер дорогой, пусть не каждый тест проходит на эту “должность”.

Ниже — таблица, которую можно держать в голове (или прямо в README для команды, если вы любите порядок и немного страдаете от хаоса в тестах):

Фильтр Вопрос, который мы задаём Что считается хорошим ответом Пример из ContentHub
DB‑specific риск Может ли embedded DB “соврать”? Да, поведение зависит от PostgreSQL (dialect/constraints/locking) Уникальность slug, порядок сортировки по timestamp, optimistic locking
Бизнес‑ценность Если это сломается — будет больно? Да, это критичный сценарий или ключевой инвариант Published list/read, workflow publish/approve, уникальный slug
Стабильность setup Можем ли мы сделать данные повторяемыми? Да: Flyway, @Sql, чёткий seed @Sql("/sql/published-articles.sql") для детерминированного порядка
Цена Контейнер окупается именно здесь? Да: один тест даёт много уверенности, иначе дешевле slice/unit 1–2 репозиторных теста + 1 интеграционный flow, но не сотни

Важно заметить тонкий момент: фильтр “Цена” — не про жадность. Цена — это скорость обратной связи, зависимость от Docker, дополнительные точки отказа и время диагностики. Контейнерный тест, который упал, требует больше внимания, чем unit‑тест: вы начинаете сомневаться, что именно сломалось — код, миграции, Docker, сеть, образ, ресурсы машины.

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

3. Кандидаты ContentHub: 3–4 теста под PostgreSQL

Здесь мы не придумываем новые формы тестов. Формы уже понятны: ArticleRepositoryPostgresContainerTest для repository path и ArticlePublicationFlowPostgresIntegrationTest для точечного full-context flow. Теперь из них собираем короткий shortlist с лучшим ROI.

Отдельный migration-only тест иногда тоже имеет смысл, если схема меняется часто и больно. Но во многих проектах этот сигнал уже дают сами container-backed тесты: они стартуют на чистой PostgreSQL и не прячут проблемы Flyway за embedded path.

Репозиторный тест: уникальность slug

Это почти обязательный кандидат: один тест, много уверенности. Он не про то, что “репозиторий умеет сохранять сущность”, а про то, что инвариант реально защищён базой.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ArticleRepositoryPostgresContainerTest {

    @Test
    void rejectsDuplicateSlug() {
        // Идея теста: база должна "запретить" дубль slug сама, через constraint.
        // 1) сохраняем первую статью со slug="same"
        // 2) пытаемся сохранить вторую статью со slug="same" и делаем flush
        // 3) ожидаем исключение о нарушении уникальности (constraint violation)
        //
        // Важно: именно flush (saveAndFlush / entityManager.flush) заставляет БД проверить constraint здесь и сейчас.
    }
}

Да, пример намеренно “скелетный”: в реальном коде это тот же repository path, который уже собран у нас в контейнерном @DataJpaTest. Меняется только выбранный риск.

Репозиторный тест: детерминированный ordering опубликованных статей

Если публичный список зависит от точного ORDER BY, один такой тест часто полезнее десятка generic CRUD-проверок. На embedded БД эти вещи иногда выглядят “случайно стабильными”.

import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;

class PublishedArticlesOrderingPostgresTest {

    @Test
    @Sql("/sql/published-articles.sql")
    void ordersByPublishedAtDesc() {
        // @Sql здесь нужен не "для красоты", а для детерминированности:
        // мы заранее задаём фиксированные publishedAt и slug, чтобы порядок был проверяемым.
        //
        // Суть:
        // 1) выполняем repository-метод с сортировкой по publishedAt DESC
        // 2) сравниваем фактический порядок slug-ов со строго ожидаемым
        //
        // Важно: если сортировка "не там" или timestamp интерпретируется иначе,
        // это проявится именно на реальной PostgreSQL.
    }
}

Почему это кандидат в subset? Потому что он одновременно проверяет query behavior, сортировку и то, что timestamps живут в реальной PostgreSQL “как в бою”.

Мини‑тест: optimistic locking (если вы уже видели пользу)

Если в проекте есть version и вы показали locking‑модель раньше, то один тест на реальной БД может дать полезный “якорь уверенности”. Это не новый фундамент suite, а точечная защита для реально неприятного класса багов.

import org.junit.jupiter.api.Test;

class ArticleOptimisticLockingPostgresTest {

    @Test
    void failsOnStaleUpdate() {
        // Суть optimistic locking:
        // 1) читаем одну и ту же сущность в двух "снимках" (две транзакции / два EntityManager)
        // 2) меняем и flush'им первый снимок — version увеличился в БД
        // 3) меняем и flush'им второй снимок — он всё ещё со старой version
        // 4) ожидаем падение на конфликте (stale update / optimistic lock exception)
        //
        // Важно: на реальной БД вы проверяете именно тот сценарий, который будет в production.
    }
}

Такой тест имеет смысл только если version и правда часть доменной модели, а не декоративная аннотация “на всякий случай”.

Full-context flow: проверка persisted effect

И наконец, один сквозной сценарий. Он должен быть не “самым большим”, а самым показательно полезным. Например, approve/publish: он меняет статус, пишет время, влияет на публичную видимость.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;

@SpringBootTest
@AutoConfigureMockMvc
class ArticlePublicationFlowPostgresIntegrationTest {

    @Test
    void approvePersistsPublishedStatus() {
        // Контейнер здесь нужен не ради HTTP, а ради "persisted effect":
        // 1) вызываем HTTP approve/publish (через MockMvc или TestRestClient)
        // 2) затем читаем состояние обратно из repository (или делаем отдельный запрос API)
        // 3) проверяем, что в БД реально записался PUBLISHED + дата публикации (если она часть инварианта)
        //
        // Идея: "ok" по HTTP может быть, а запись в БД — нет. Контейнер ловит именно это.
    }
}

Здесь важно помнить правило: в контейнерном full-context тесте нельзя останавливаться на status().isOk(). Контейнер нужен не для проверки статуса HTTP, а чтобы убедиться, что реальная БД приняла правильное состояние, и что оно читается обратно.

4. Shared PostgreSQL setup: общий конфиг

Как только subset перестаёт быть “одним тестом для эксперимента”, появляется нормальное желание: “А давайте не копировать одно и то же объявление контейнера в каждом классе”. Желание хорошее. Но есть тонкая грань: вы можете либо получить аккуратный shared config на 10 строк, либо выстроить “магическую” иерархию, где контейнер стартует где-то глубоко, и никто не понимает, почему тест вообще стал container‑based.

Правильная цель переиспользования звучит так: уменьшить копипасту, не пряча причины дороговизны теста.

Прозрачный shared config через @TestConfiguration

Самый учебно‑дружелюбный вариант — отдельная тестовая конфигурация, которую вы импортируете в выбранные тесты. Она маленькая, читается “в один прыжок”, и по импорту видно, что тест особенный.

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;

@TestConfiguration
class SharedPostgresContainerConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgres() {
        // Контейнер должен быть максимально предсказуемым:
        // фиксируем версию образа, чтобы поведение в CI и локально совпадало.
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

И подключение:

import org.springframework.context.annotation.Import;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest
@Import(SharedPostgresContainerConfig.class) // Явно видно: тест требует контейнер
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ArticleRepositoryPostgresContainerTest {
    // Здесь специально нет "магии":
    // по одному @Import понятно, почему тест дорогой и почему ему нужен Docker.
}

Обратите внимание на “читабельность намерения”: по одному @Import(SharedPostgresContainerConfig.class) видно, что тест не обычный. А по имени теста (...PostgresContainerTest) видно, почему он дорогой.

“BasePostgresTest” часто хуже

Очень популярный путь — сделать абстрактный класс вроде AbstractPostgresContainerTest и наследоваться. Технически он работает, но методически он плох тем, что скрывает инфраструктуру “куда-то вниз”. Через пару месяцев вы открываете тест и видите только extends AbstractPostgresContainerTest, а дальше начинается археология.

Если уж вы делаете общий класс, он должен быть максимально “немагическим”: без хитрых override’ов, без условного старта, без “давайте ещё логгер настроим”, без “переопределим половину приложения”. Но даже тогда импорт конфигурации часто остаётся понятнее для Junior‑аудитории.

5. Naming и структура: дорогие тесты не маскируем

Когда subset небольшой, проблема “где он лежит” кажется надуманной. Но как только появляются 5–10 контейнерных тестов (а это уже много для курса), вы внезапно начинаете путаться: какие тесты требуют Docker, какие можно гонять везде, какие являются “late booster”.

Хорошая новость: это решается простыми правилами именования, без отдельного фреймворка.

Нормальная договорённость для ContentHub выглядит так: в названии тестового класса прямо написано, что он контейнерный и что он про PostgreSQL. Например, ArticleRepositoryPostgresContainerTest и ArticlePublicationFlowPostgresIntegrationTest. Даже если человек открыл файл на телефоне в метро и у него нет контекста, он уже понял: “ага, Docker нужен”.

Можно также держать контейнерные тесты в отдельном пакете или подпакете, но тут важно не перестараться. Если вы слишком разнесёте тесты по пакетам, вы создадите дополнительную “навигационную боль”. Обычно достаточно, чтобы они лежали рядом с обычными тестами слоя, но отличались именем, например:

.../repository/ArticleRepositoryDataJpaTest — быстрый data-slice,
.../repository/ArticleRepositoryPostgresContainerTest — дорогой, DB‑specific subset.

Это помогает сохранить mental model: “это тот же слой, но другой runtime”.

6. Признаки расползания subset и подрезка

Есть очень практический критерий, который почти никогда не подводит: если вы не можете перечислить container-based tests поштучно — значит, subset уже не subset, а “контейнеры везде, потому что так вышло”.

Ещё один маркер — дублирование риска. Например, вы проверяете уникальность slug в обычном @DataJpaTest на embedded, потом то же самое в контейнере, потом ещё раз через full-context flow с контейнером. В итоге вы тратите время трижды, а уверенность растёт не в три раза, а максимум в полтора (и то, если повезёт). Хороший subset обычно даёт принцип “один риск — один самый сильный тест” и оставляет остальное слоям ниже.

Похожий симптом — когда контейнер “по привычке” попадает в web-slice или JSON‑тесты. Если вы подключили PostgreSQL к @WebMvcTest, это почти всегда значит, что где-то потеряна граница слоя. Web-slice проверяет HTTP-границу; ему не должно быть интересно, на чём живёт база, если вы не делаете full wiring (а @WebMvcTest его и не делает по смыслу).

И наконец, психологический симптом: тесты становятся настолько медленными, что вы запускаете их “только перед релизом”. В учебном проекте это особенно грустно, потому что вы теряете обратную связь, а тесты превращаются в музейный экспонат “на всякий случай”.

7. Типичные ошибки при работе с subset

Ошибка №1: перевод всего пакета @DataJpaTest на контейнеры.
Это выглядит как усиление качества, но обычно превращается в замедление разработки и падение доверия к suite. Большая часть repository‑тестов не чувствительна к конкретному dialect, а значит контейнер там не добавляет новой информации. Гораздо сильнее работает отбор: 1–2 теста на constraints и ordering дают больше уверенности, чем десятки “контейнерных CRUD”.

Ошибка №2: общий контейнерный setup превращается в магию.
Когда контейнер прячется в глубокой иерархии базовых классов или в “супер‑утилитах”, новички перестают понимать, почему тест дорогой и что ему нужно. Потом появляются “случайные” падения, и команда начинает лечить симптомы, а не причины. Shared config должен быть коротким и видимым: @Import(SharedPostgresContainerConfig.class) — это почти идеальная степень прозрачности.

Ошибка №3: full-context тест проверяет только HTTP-статус.
Если вы подняли реальный PostgreSQL и полный контекст, а в тесте написали только status().isOk(), вы сделали дорогую проверку дешёвого свойства. HTTP‑статус отлично проверяется в web-slice, и даже в full-context без контейнера. Контейнер оправдан тогда, когда вы подтверждаете persisted effect: статус статьи в базе, уникальность slug, корректную дату публикации, read‑back через repository.

Ошибка №4: отсутствие детерминированных данных и “плавающий порядок”.
Container-based тесты часто пишут “в спешке” и забывают, что порядок выдачи без явной сортировки — не обещание, а удача. А удача в тестах обычно заканчивается на CI. Для ordering‑сценариев используйте @Sql с понятными датами/slug‑ами и проверяйте ожидаемый порядок строго. Если тест должен быть строгим, данные должны быть строгими тоже.

Ошибка №5: контейнер подключают к тестам, которым он не нужен.
Если хочется тащить PostgreSQL в unit, JSON или @WebMvcTest, это почти всегда сигнал, что subset уже расползается. Здесь лучше остановиться и снова спросить: какой именно DB-specific риск мы собираемся поймать. Если ответа нет, контейнер, скорее всего, лишний.

1
Задача
Spring Test, 25 уровень, 4 лекция
Недоступна
Маленький subset из быстрого и дорогого repository-теста
Маленький subset из быстрого и дорогого repository-теста
1
Задача
Spring Test, 25 уровень, 4 лекция
Недоступна
Скрипт для запуска только container-based subset
Скрипт для запуска только container-based subset
1
Опрос
PostgreSQL тесты, 25 уровень, 4 лекция
Недоступен
PostgreSQL тесты
Контейнерные тесты с PostgreSQL
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ