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 риск мы собираемся поймать. Если ответа нет, контейнер, скорее всего, лишний.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ