1. Контекст: снова о базе данных
Если честно, тестирование базы данных напоминает разговоры про зарядку утром. Все понимают, что полезно, но никто не хочет начинать с “тяжёлой артиллерии” в понедельник в 7 утра. В нашем курсе это сделано специально: мы уже много дней учились получать уверенность дешёво и быстро, а сегодня добавляем дорогую опцию — ровно там, где она реально повышает доверие.
Ключевой момент: Testcontainers и real PostgreSQL path — это не новый фундамент курса, а поздний усилитель. Мы не переписываем весь test suite “на контейнеры”, мы не превращаем курс в Docker‑курс, и уж точно не делаем “контейнер вместо архитектуры”. Мы добавляем инструмент, который нужен только в тех местах, где поведение зависит именно от PostgreSQL, а не просто “от какой-то базы”.
Чтобы не потеряться, давайте прямо сейчас зафиксируем две мысли. Первая: большая часть тестов ContentHub (unit, JSON, узкие MVC tests) вообще не должна знать о PostgreSQL — потому что она проверяет другие риски. Вторая: некоторые data‑ и integration‑сценарии действительно чувствительны к конкретной СУБД, миграциям и диалекту, и вот там “настоящая” PostgreSQL перестаёт быть роскошью.
Real PostgreSQL path и embedded база
Когда говорят “embedded database”, чаще всего имеют в виду вариант “быстро поднять какую-то встроенную БД для тестов” (например, H2), чтобы @DataJpaTest работал без внешней инфраструктуры. Это реально удобно: тесты быстрые, запускаются везде, не требуют Docker, и новичок может работать даже на ноутбуке, который видел PostgreSQL только на картинках.
Проблема в том, что embedded база — это другая СУБД, с другим диалектом SQL, другими ограничениями и иногда другими “тонкими правилами”. Она может быть прекрасной в роли “учебного полигона” и “быстрого фильтра регрессий”, но она не обязана совпадать с PostgreSQL в деталях. А в backend‑разработке именно детали любят ломать прод.
Real PostgreSQL path — это когда тесты запускаются против PostgreSQL, причём желательно так, чтобы разработчику не приходилось держать вручную поднятую БД “где-то там”. И вот тут появляется Testcontainers: библиотека, которая позволяет поднимать временную PostgreSQL в контейнере прямо на время тестов.
Важно: реальная база — это отдельная ось, независимая от уровня теста. Вы можете запускать @DataJpaTest против PostgreSQL, а можете запускать @SpringBootTest против PostgreSQL. Это разные уровни проверки, но один и тот же “database path”.
3. Две оси выбора: уровень теста и путь к базе данных
Очень легко сделать ошибку и “склеить” в голове два разных решения: “поднимаем полный контекст” и “используем реальную PostgreSQL”. На практике это разные кнопки, и нажимать их надо отдельно, а не всегда обе сразу.
После этого курса у вас должна появиться привычка думать матрицей: по одной оси — уровень теста, по другой — тип базы.
| Что выбираем | Дешевле | Дороже | Главный смысл |
|---|---|---|---|
| Уровень теста | @DataJpaTest / slices | @SpringBootTest | Проверяем только слой или проверяем wiring между слоями |
| Путь к БД | embedded | real PostgreSQL (контейнер) | Проверяем “примерно как в проде” именно поведение СУБД |
И вот почему это важно: если вам нужно доказать, что уникальность slug реально enforced на уровне PostgreSQL, чаще всего достаточно @DataJpaTest + PostgreSQL. Поднимать ещё и @SpringBootTest в этот момент — это как поехать за хлебом на танке: эффектно, но соседи начнут задавать вопросы.
Чтобы закрепить это как инженерную схему, можно представить decision flow так:
flowchart TD
A[Нужно написать тест] --> B{Риск зависит от PostgreSQL?}
B -- нет --> C[Остаёмся на дешёвом пути: unit / slice / embedded]
B -- да --> D{Можно доказать риск на data-layer?}
D -- да --> E["@DataJpaTest + real PostgreSQL"]
D -- нет --> F["@SpringBootTest + real PostgreSQL selected"]
Мы сегодня занимаемся в основном первым вопросом: как понять, что риск действительно зависит от PostgreSQL и вообще заслуживает контейнера.
4. Embedded база может “врать”: типовые расхождения
В предыдущих днях мы уже говорили, что embedded path иногда начинает “врать”, но сейчас нам нужно это превратить в практическую интуицию. Не в философию “все базы разные”, а в конкретные причины: какие именно вещи могут пройти в embedded и упасть на PostgreSQL (или наоборот). И, что важнее, какие из них реально актуальны для ContentHub.
Миграции Flyway и диалект SQL
Если ваш проект использует Flyway, то схема базы данных — это не “побочный эффект JPA”, а отдельный артефакт, который должен выполняться на реальной СУБД. У embedded базы может быть другой синтаксис, другие типы, другие ограничения — и вы можете случайно получить ситуацию, когда миграции на H2 проходят, а на PostgreSQL падают, или наоборот.
Для ContentHub это особенно критично, потому что у нас есть таблицы article, category, article_attachment, ограничения, индексы, возможно последовательности/identity и т.д. Если миграция “слегка неправильная”, вы хотите узнать это из тестов, а не из сообщения коллеги “почему staging не поднимается”.
DB-level constraints и индексы
Мы уже тестировали unique и not-null ограничения. Но есть тонкость: разные базы по-разному ведут себя в деталях вроде типов, длины, collation, работы с null в уникальных индексах и даже в сообщениях об ошибках. Иногда “вроде бы unique” в embedded проявляется иначе, чем в PostgreSQL.
В ContentHub один из главных инвариантов — уникальность slug. Формально это простой unique constraint. Но именно такие вещи любят ломаться из‑за схемы, миграций и “случайно не того индекса”. И это как раз тот случай, когда тест на реальной PostgreSQL даёт хорошую уверенность: он проверяет не “как мы думаем”, а “как реально будет”.
Запросы, сортировки и пагинация
Тесты на сортировку — это место, где embedded база может быть коварной. Она может по-другому сортировать строки, по-другому трактовать регистр, по-другому выдавать null в ORDER BY, по-другому оптимизировать запрос, и вы получите красивые зелёные тесты, а в PostgreSQL “порядок чуть другой”.
В ContentHub у нас есть сценарии вроде “публичная выдача опубликованных статей по publishedAt desc”. Клиенту важно, чтобы порядок был предсказуемым. Если вы хотите зафиксировать конкретный ordering, а не просто факт “какие-то статьи вернулись”, то проверка на реальной PostgreSQL может быть оправдана.
Locking, транзакции и конкурентные “краешки”
Мы показывали optimistic locking basics через version. На уровне JPA это выглядит “одинаково”, но на уровне реальной базы есть нюансы: изоляция транзакций, поведение при параллельных обновлениях, точные исключения, которые вы получаете при конфликте.
Для курса мы не превращаемся в курс по конкурентности. Но если вы выбираете один-два теста, которые демонстрируют locking‑чувствительное поведение на реальной PostgreSQL, это может дать вам очень приятное чувство “я видел это вживую, а не в презентации”.
5. Цена контейнерного пути: за что вы платите
Если вы когда-нибудь слышали “давайте всё проверим интеграционными тестами”, вы знаете, что это обычно заканчивается грустью. Testcontainers — штука полезная, но платить за неё вы будете вполне настоящими ресурсами: временем и стабильностью.
Во-первых, контейнеры нужно запускать. Даже если PostgreSQL‑образ уже скачан, старт контейнера и готовность базы занимают время. По сравнению с embedded базой это почти всегда медленнее, иногда заметно медленнее. Да, это можно оптимизировать, но мы сегодня договоримся о более здоровой стратегии: не оптимизировать бесконечно дорогой путь — а сделать дорогой путь маленьким.
Во-вторых, контейнеры добавляют дополнительные точки отказа. Нужен работающий Docker Engine, нужны ресурсы на машине, иногда возникают проблемы прав доступа или ограничений корпоративного ноутбука. Это не “ужас-ужас”, это просто реальность. И именно поэтому контейнерный путь — поздний и точечный.
В-третьих, дорогие тесты имеют свойство “обрастать смыслом”. Раз уж вы подняли PostgreSQL, появляется соблазн добавить ещё один тест… потом ещё… потом “а давайте весь data‑suite сюда”… и вы незаметно превращаете быстрый набор тестов в медленного монстра, которого никто не любит запускать локально.
Поэтому наша цель дня 25 формулируется почти цинично: получить максимум уверенности за минимум инфраструктурной боли. А это автоматически означает: нужен container-based subset, небольшой и объяснимый.
6. Когда real PostgreSQL path окупается
Сейчас мы делаем самую важную вещь этой лекции: превращаем “вроде бы полезно” в понятный отбор. Представьте, что вы в команде и кто-то предлагает: “Давайте всё гонять в Testcontainers”. Вам нужно уметь спокойно ответить: “Окей, а какой риск мы ловим, и почему он не ловится дешевле?”.
Хороший критерий окупаемости почти всегда звучит так: мы подозреваем, что поведение зависит от конкретной СУБД (PostgreSQL), и хотим зафиксировать это тестом.
На практике это выражается в нескольких типах ситуаций.
Первый тип — миграции и schema drift. Если ваш тест должен доказать “миграции действительно применяются на PostgreSQL”, то embedded не подходит по определению. Здесь Testcontainers очень уместен, потому что вы проверяете именно PostgreSQL‑правду: SQL‑тип, индекс, ограничение — всё “как в жизни”.
Второй тип — DB‑уровневые инварианты и ограничения. Unique constraint на slug, not-null поля, ограничения на длины, внешние ключи — всё, что должно защищать ваш домен даже если код ошибся. Это зона ответственности базы, и проверять её на “другой базе” — иногда нормально, но иногда рискованно.
Третий тип — dialect-sensitive queries и ordering. Если вы пишете запрос, который использует PostgreSQL‑специфичные возможности (или просто ведёт себя тонко на разных СУБД), и этот запрос реально критичен для API, тогда один-единственный тест на реальной PostgreSQL может быть выгоднее, чем неделя ловли “почему на staging иначе”.
Четвёртый тип — locking/конкурентные краешки. Опять же, без фанатизма: 1–2 сценария для демонстрации и защиты реально важного поведения.
И отдельно нужно уметь честно сказать “нет” контейнеру, когда он не даёт пользы. Если риск не зависит от PostgreSQL, то PostgreSQL в тесте — это просто дорогая декорация.
7. Когда PostgreSQL почти ничего не добавляет
Очень важно не превратить Testcontainers в “премиальную версию тестов”. Он не делает тест автоматически умнее. Он делает его ближе к реальности базы, и всё. Если риск не в базе — контейнер не лечит.
Unit‑тесты бизнес-правил (PublicationPolicy, проверки переходов статусов) не становятся надёжнее от PostgreSQL, потому что они не про базу. Это “чистая логика”, и лучший способ сделать её надёжной — держать тест простым и быстрым.
JSON‑тесты не становятся надёжнее от PostgreSQL, потому что сериализация DTO и контракт ApiProblem вообще не зависят от СУБД.
Узкие MVC tests (@WebMvcTest) не должны тащить PostgreSQL за собой, если они проверяют HTTP‑контракт и поведение controller/advice/validation, а не тонкости SQL.
Даже security tests во многих случаях не нуждаются в PostgreSQL. Да, у security могут быть зависимости от пользователей в БД в реальном проекте, но в нашем курсе baseline проще, и большая часть security‑логики проверяется на уровне доступа и статусов, а не DB‑диалекта.
Эта часть важна психологически: вы не “плохой тестировщик”, если у вас мало контейнерных тестов. Наоборот, если вы можете поштучно объяснить каждый контейнерный тест — это часто признак зрелости.
8. Примеры кандидатов для real DB path
Сейчас сделаем пару мини‑примеров, чтобы ваш мозг связывал разговор о рисках с конкретными классами тестов. Примеры маленькие и нарочно слегка “скелетные”: здесь важно не wiring, а умение узнавать, где PostgreSQL вообще имеет смысл.
В примерах ниже используем JUnit 6.
Unit‑тест PublicationPolicy: PostgreSQL здесь просто не при чём
В этом тесте мы проверяем чистое бизнес‑правило: можно ли переводить статью из DRAFT в IN_REVIEW. База данных тут не участвует вообще, и это прекрасно: тест быстрый и не ломается из‑за инфраструктуры.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
@Test
void allowsDraftToReview() {
// Создаём объект доменной политики: это чистая логика, без БД и Spring
var policy = new PublicationPolicy();
// Проверяем конкретное правило перехода статусов
assertThat(policy.canMove(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW)).isTrue();
}
}
Если такой тест “усилить” PostgreSQL, вы не поймаете больше дефектов — вы просто будете дольше ждать результат.
Обычный @DataJpaTest: полезен и без контейнера
Вот типичный data‑slice тест на репозиторий. Он может быть очень ценным и без PostgreSQL‑контейнера: проверить запрос, связи, базовую семантику. Контейнер мы подключаем только если есть DB‑чувствительный риск.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // Поднимаем только JPA-слой (репозитории/EntityManager), без полного контекста
class ArticleRepositoryDataJpaTest {
@Autowired
ArticleRepository articleRepository; // Spring внедряет тестируемый репозиторий
@Test
void findsPublishedBySlug() {
// В реальном тесте здесь обычно есть подготовка данных (fixture) перед поиском
// Проверяем, что репозиторий умеет находить опубликованную статью по slug
assertThat(articleRepository.findPublishedBySlug("spring-boot-testing")).isPresent();
}
}
Сама по себе такая проверка хорошая. Но переносить её в контейнер “по привычке” — не лучшая идея.
“Сигнал”, что тест DB‑чувствительный: replace = NONE
Когда вы видите в тесте @AutoConfigureTestDatabase(replace = NONE), это почти всегда означает: автор теста сознательно решил “не подменять datasource”. То есть тест хочет работать с тем источником данных, который мы считаем “настоящим” (в нашем случае — PostgreSQL в контейнере).
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Не подменяем DataSource на embedded
class ArticleRepositoryPostgresContainerTest {
// Здесь мы фиксируем намерение: этот тестовый класс будет работать с "реальной" БД (PostgreSQL в контейнере)
}
Этот класс пока пустой — и это нормально: здесь мы фиксируем только намерение. Для этого места важно увидеть сам выбор — тест сознательно идёт в real DB path, а конкретные проверки уже будут строиться вокруг constraints, ordering или locking.
Как увидеть кандидатов в ContentHub
Сейчас полезно связать критерии окупаемости с нашими конкретными сущностями и сценариями. ContentHub — это не абстрактная база данных, а вполне конкретные риски.
| Сценарий в ContentHub | Почему это DB‑чувствительно | Какой тип теста чаще всего достаточно силён |
|---|---|---|
| Уникальность Article.slug | Это инвариант на уровне схемы/индекса, ошибка тут бьёт по публичным ссылкам | @DataJpaTest + real PostgreSQL |
| Миграции Flyway для таблиц и индексов | SQL должен быть валиден именно для PostgreSQL | 1 контейнерный test, который поднимает контекст и проходит миграции |
| Public list ordering (publishedAt desc) | Сортировки и null‑края могут вести себя по-разному на разных СУБД | @DataJpaTest + PostgreSQL для выбранного запроса |
| Optimistic locking (version) | Нюансы конкурентного поведения лучше увидеть на реальной базе | 1 демонстрационный @DataJpaTest/integration test |
Обратите внимание, что здесь нет “проверить DTO сериализацию” или “проверить, что контроллер возвращает 200”. Это не потому, что они не важны — они важны, просто PostgreSQL там ничего не добавляет.
9. Типичные ошибки при выборе real PostgreSQL path
Ошибка №1: “раз уж можем, давайте переведём все data‑tests на контейнеры”.
Это звучит логично, но почти всегда убивает скорость обратной связи. В результате разработчики перестают запускать тесты локально, а suite превращается в “что-то для CI”. Правильнее держать контейнерный путь маленьким: только DB‑чувствительные сценарии, которые действительно окупаются.
Ошибка №2: попытка усилить контейнером unit‑тесты.
Unit‑тесты ценны тем, что они быстрые и изолированные. Если вы начинаете “поднимать Postgres ради PublicationPolicy”, вы не добавляете уверенности — вы просто усложняете запуск. Бизнес‑правило не станет вернее от того, что рядом работает база.
Ошибка №3: выбор trivial CRUD как главного кандидата для контейнерного пути.
Сохранить и прочитать сущность — полезно, но если там нет DB‑чувствительного риска, контейнер чаще всего не окупается. Гораздо выгоднее выбрать тест, который реально ловит расхождение: миграция, индекс, ordering, диалектный запрос, locking‑край.
Ошибка №4: смешивание “реальная база” и “полный контекст” как обязательной пары.
Иногда люди думают: “Если Postgres — значит @SpringBootTest”. Нет. Если риск лежит в репозитории и схеме, @DataJpaTest против PostgreSQL даст сильную проверку гораздо дешевле, чем полный контекст.
Ошибка №5: контейнер подключили, а смысловую проверку забыли.
Бывает тест “контейнерный” по инфраструктуре, но по сути проверяет только status().isOk() или “метод вернул не null”. Контейнер сам по себе ничего не доказывает. Он имеет смысл только вместе с проверкой реального эффекта: constraint violation, миграции, ordering, read-back из базы после записи.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ