JavaRush /Курсы /Spring Test /PostgreSQL через @Service...

PostgreSQL через @ServiceConnection

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

1. Preferred-path и @ServiceConnection

Когда разработчик впервые слышит “Testcontainers”, у него часто возникает план уровня “сейчас я напишу 40 строк конфигурации и победю инфраструктуру”. Работает, но обычно это путь к копипасте, к неочевидным property overrides и к тестам, которые сложно объяснить через пару недель. Preferred-path в Boot 4 — это наоборот: минимум ручной работы и максимум читаемого смысла.

Когда уже понятно, что риск правда упирается в PostgreSQL, следующая проблема совсем не философская: как подключить контейнер так, чтобы тест не превратился в склад ручных spring.datasource.*. Для стандартного datasource-сценария хочется пути покороче, и здесь @ServiceConnection как раз убирает лишний plumbing.

Идея @ServiceConnection очень простая (и от этого прекрасная): вы говорите Spring Boot “вот контейнер, это и есть моя тестовая база”, а Boot сам вытаскивает из контейнера параметры подключения и подставляет их в окружение. То есть вы перестаёте быть человеком, который вручную таскает JDBC URL по тесту, и становитесь человеком, который пишет тесты. Это, как ни странно, больше похоже на жизнь.

Если совсем в одну фразу, @ServiceConnection — это встроенный в Spring Boot “переходник” контейнер → настройки подключения, который экономит вам и время, и количество мест, где можно ошибиться. А ошибок в тестах мы не любим по той же причине, по которой не любим баги в проде: они портят настроение, а настроение — важная часть инженерии.

Минимальные зависимости для Testcontainers

Прежде чем мы красиво подключим контейнер аннотацией, нужно сделать банальную вещь: чтобы у проекта вообще были классы Testcontainers и интеграция Spring Boot с контейнерами. Это тот момент, где новичок часто говорит “но у меня же уже есть spring-boot-starter-test”, а потом удивляется, что PostgreSQLContainer не импортируется. Starter хорош, но не телепат.

В ContentHub у нас базовая идея такая: Testcontainers — это test-only зависимость, и нам нужен “двойной” набор: сам Testcontainers (и модуль PostgreSQL), плюс spring-boot-testcontainers, чтобы Boot понял @ServiceConnection и умел автоматически связать контейнер с автоконфигурацией datasource.

Пример для build.gradle.kts, намеренно короткий и “без магии”:

dependencies {
    testImplementation("org.springframework.boot:spring-boot-starter-test") // JUnit 6, Spring Test, AssertJ и т.д.
    testImplementation("org.springframework.boot:spring-boot-testcontainers") // поддержка @ServiceConnection в тестах
    testImplementation("org.testcontainers:junit-jupiter") // интеграция Testcontainers с JUnit 6 (Jupiter API)
    testImplementation("org.testcontainers:postgresql") // PostgreSQLContainer и PostgreSQL-специфика
}

Обратите внимание на важный нюанс: версии в курсе фиксируются Boot-стеком (мы не собираем “зоопарк” вручную), поэтому в реальном проекте чаще всего вы либо не пишете версии вообще, либо пишете их только там, где ваш dependency management не подхватил нужное. В учебном проекте мы держим это согласованным через общий platform stack курса — и это как раз то, что делает такую интеграцию воспроизводимой.

3. Field-based контейнер в @DataJpaTest

Самый прямой сценарий для ContentHub — это репозиторные тесты, которые мы осознанно решили гонять против реального PostgreSQL. И тут хочется, чтобы тест читался так: “это @DataJpaTest, но datasource приходит из контейнера”. Field-based стиль делает это максимально очевидно: контейнер лежит прямо в тестовом классе как поле, и JUnit управляет его жизненным циклом.

Ключевой плюс этого варианта в том, что вы одним взглядом понимаете, где спрятана инфраструктура теста, и не бегаете по конфигурационным классам. Минус тоже честный: если вы начнёте копировать это поле в 10 тестов, то инфраструктура начнёт расползаться. Поэтому мы в этой лекции покажем как “в лоб”, так и вариант с переиспользованием через @TestConfiguration (чуть позже).

Скелет ArticleRepositoryPostgresContainerTest

Начнём с того, что обычно хочется сделать первым: поднять контейнер и убедиться, что Spring Data JPA тест стартует на реальной базе.

import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers // расширение Testcontainers управляет контейнерами, отмеченными @Container
@DataJpaTest // slice-тест: поднимается JPA-слой (EntityManager, репозитории и т.п.)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // не подменять DataSource на embedded
class ArticleRepositoryPostgresContainerTest {

    @Container // этот контейнер будет стартовать/останавливаться Testcontainers
    @ServiceConnection // Boot берёт отсюда url/user/password и настраивает DataSource автоматически
    static PostgreSQLContainer
   postgres =
            new PostgreSQLContainer<>("postgres:16-alpine"); // образ PostgreSQL для тестов
}

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

@Testcontainers подключает JUnit-расширение Testcontainers, которое знает, как стартовать и останавливать контейнеры, отмеченные @Container. Аннотация @Container говорит “вот конкретно этот объект контейнера нужно запускать”. А @ServiceConnection — ключ лекции — говорит Spring Boot “используй этот контейнер как источник параметров подключения”.

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

static контейнер

Если вы оставите контейнер не static, то он будет создаваться (и потенциально стартовать) на каждый экземпляр тестового класса. А экземпляр тестового класса в JUnit 6 обычно создаётся на каждый тест-метод. Результат: контейнер начинает жить жизнью майонеза в холодильнике — “то он есть, то его нет, и вообще почему всё так долго”.

Сделав контейнер static, вы говорите: “контейнер — ресурс уровня класса”. JUnit будет запускать его один раз на класс и останавливать после. Это резко снижает цену запуска тестов (в рамках одного класса) и делает поведение более предсказуемым.

Чтобы почувствовать эффект, представьте разницу между “поднять PostgreSQL один раз и прогнать 10 тестов” и “поднять PostgreSQL 10 раз”. Во втором случае вы не тестируете приложение — вы тестируете терпение разработчика. И, как показывает практика, терпение разработчика не проходит миграции Flyway так же бодро, как PostgreSQL.

@AutoConfigureTestDatabase(replace = NONE)

У @DataJpaTest есть важная особенность: по умолчанию Spring Boot любит подменять datasource на embedded-вариант (если он доступен в зависимостях), потому что для многих проектов это самый быстрый путь. Но в контейнерном тесте мы как раз хотим не подменять, а честно использовать PostgreSQL из контейнера.

Поэтому @AutoConfigureTestDatabase(replace = NONE) здесь не “для красоты”, а чтобы сказать Boot: “не лезь со своей добротой, datasource уже выбран”. Можно воспринимать это как табличку “не мешайте, я работаю” на двери, только в виде аннотации.

4. Как Boot обрабатывает @ServiceConnection

Чтобы не относиться к @ServiceConnection как к магическому заклинанию из книги “Spring для смелых”, полезно держать в голове простую модель происходящего. Мы не лезем в внутренности Boot (иначе у нас будет курс “как читать исходники Boot”), но нам нужно понимать причинно-следственную связь: почему оно работает и почему это короче, чем ручные свойства.

В случае PostgreSQL контейнер запускается, получает динамический порт и формирует JDBC URL вида jdbc:postgresql://localhost:54321/.... Порт каждый раз может быть разный (это нормально), поэтому “зашить” URL в application-test.yml нельзя. Дальше Spring Boot видит аннотацию @ServiceConnection и автоматически регистрирует connection details в окружении приложения. А потом обычная автоконфигурация datasource делает то, что она умеет лучше всего: создаёт DataSource, используя эти детали.

Можно представить это так:

flowchart TD
    A[JUnit + Testcontainers] --> B[Стартует PostgreSQLContainer]
    B --> C[Контейнер выдаёт JDBC URL / user / password]
    C --> D["@ServiceConnection: Boot подхватывает connection details"]
    D --> E[AutoConfiguration создаёт DataSource]
    E --> F[Flyway/JPA работают с реальным PostgreSQL]
    F --> G[Тест выполняет сценарий и проверяет DB state]

Самое важное чувство, которое нужно вынести: @ServiceConnection — это как будто Spring Boot сам написал за вас тот код, который вы бы написали в @DynamicPropertySource, но сделал это короче, единообразнее и с меньшим количеством мест для “ой, я забыл пароль”.

И да, именно поэтому @ServiceConnection — preferred-path. Он делает то же самое, что ручная регистрация свойств, но обычно быстрее в написании, проще в чтении и лучше в поддержке. А уже в следующей лекции мы посмотрим, что делать, когда автоматического пути по каким-то причинам недостаточно — но пока остаёмся в мире простых побед.

5. Full-context: @SpringBootTest и контейнер

Очень полезно увидеть, что один и тот же способ wiring контейнера работает не только для @DataJpaTest, но и для полного контекста. Потому что контейнер — это не “аннотация для репозиториев”, это просто способ дать приложению реальную базу данных в тесте. А какой уровень теста вы выбрали (slice или full-context) — это уже отдельная инженерная ось.

Форма теста здесь другая, способ подключения базы — тот же. Если конкретный риск нельзя доказать на repository-level, тот же контейнер спокойно переезжает в @SpringBootTest.

Мы в курсе постоянно удерживаем мысль: контейнер — дорогой инструмент, поэтому мы используем его точечно. Но если вы решили, что конкретный сквозной сценарий должен быть проверен именно на PostgreSQL (например, потому что там важны миграции, timestamp’ы или DB-level ограничения), то @SpringBootTest + контейнер вполне уместны — просто их должно быть мало.

Скелет ArticlePublicationFlowPostgresIntegrationTest

Вот минимальный каркас full-context теста, где контейнер подключён тем же preferred-path.

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers // Testcontainers-расширение управляет жизненным циклом контейнеров
@SpringBootTest // full-context: поднимается весь ApplicationContext
@AutoConfigureMockMvc // если пойдём через MockMvc, он уже будет в контексте
class ArticlePublicationFlowPostgresIntegrationTest {

    @Container // контейнер стартует до поднятия контекста и останавливается после тестов класса
    @ServiceConnection // Boot сам подставит свойства подключения к БД из контейнера
    static PostgreSQLContainer
   postgres =
            new PostgreSQLContainer<>("postgres:16-alpine"); // PostgreSQL для интеграционных тестов
}

Выглядит почти подозрительно просто, но смысл такой: Spring Boot поднимает полный ApplicationContext, datasource берётся из контейнера, миграции прогоняются, JPA готова, и вы можете вызывать либо сервисы напрямую (если тест не про HTTP), либо идти через MockMvc/RestTestClient — в зависимости от выбранного режима интеграционного теста.

Если вы добавите @AutoConfigureMockMvc, то сможете пройти цепочку controller → service → repository, и это уже будет полноценный cross-layer тест с реальной базой. Он дорогой. Поэтому мы делаем его осознанно, а не “потому что можем”.

Как не сделать integration-test “просто статус 200”

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

Пример (упрощённый), где мы не только делаем HTTP-запрос, но и читаем состояние из репозитория обратно:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// Фрагмент внутри ArticlePublicationFlowPostgresIntegrationTest
@Autowired
MockMvc mockMvc;

@Autowired
ArticleRepository articleRepository;

@Test
void approvePersistsPublishedStatus() throws Exception {
    mockMvc.perform(post("/api/admin/articles/{id}/approve", 42L))
            .andExpect(status().isOk()); // HTTP-уровень: запрос прошёл

    // DB-уровень: проверяем наблюдаемый эффект в persistence
    assertThat(articleRepository.findById(42L).orElseThrow().getStatus())
            .isEqualTo(ArticleStatus.PUBLISHED);
}

Да, здесь появилось два “набора” assertions: HTTP-level (статус) и DB-level (состояние статьи). И это нормально, потому что такой тест по смыслу — сквозной. Он должен доказать, что запрос прошёл через приложение и оставил корректный след в persistence.

Заметьте: мы не проверяем “какие методы репозитория вызвались” (это было бы interaction testing), и не проверяем “какой SQL сгенерировался” (это уже другой тип задач). Мы проверяем поведение: после approve статья стала PUBLISHED. Это тот уровень “правды”, который реально нужен в регрессии.

6. Bean-based: контейнер в @TestConfiguration

Field-based способ очень нагляден, но у него есть очевидная проблема: когда у вас появляется несколько container-backed тестовых классов, вы начинаете копировать один и тот же кусок кода с static PostgreSQLContainer. В какой-то момент кто-то поменяет версию образа в одном месте и забудет в другом, и вы получите “весёлую” ситуацию: часть тестов гоняется на PostgreSQL 16, а часть — на PostgreSQL 17. И это не потому, что вы так задумали, а потому что у копипасты нет чувства ответственности.

Bean-based вариант решает это так: контейнер описывается как test-only bean в @TestConfiguration, и дальше вы подключаете его там, где он нужен. Получается чище, и переиспользование становится управляемым.

Если container-backed классов уже несколько, такой extraction начинает окупаться довольно быстро.

SharedPostgresContainerConfig

Вот пример test-only конфигурации, которая объявляет PostgreSQL контейнер как bean и помечает его @ServiceConnection.

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

@TestConfiguration // будет подключаться только в тестовом контексте
class SharedPostgresContainerConfig {

    @Bean // делаем контейнер управляемым bean'ом Spring'а
    @ServiceConnection // Boot сам возьмёт url/user/password и настроит DataSource
    PostgreSQLContainer
   postgres() {
        // В этом стиле жизненным циклом управляет Spring (а не JUnit через @Testcontainers/@Container)
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

Смысл здесь такой: Spring Boot увидит bean типа PostgreSQLContainer, стартанёт его (как управляемый ресурс тестового контекста) и автоматически использует его connection details для datasource. Вам не нужны @Testcontainers и @Container, потому что жизненным циклом управляет уже Spring, а не JUnit.

Этот стиль особенно приятен, когда вы хотите, чтобы контейнерный setup был “один на группу тестов” и менялся централизованно. В учебном проекте ContentHub это вполне реалистично: вы выбрали 2–3 теста на PostgreSQL и хотите, чтобы все они использовали один и тот же образ и одну и ту же конфигурацию контейнера.

Подключение через @Import

Дальше нужный тест просто импортирует конфигурацию. В результате тест выглядит как “обычный @DataJpaTest, но с контейнерной базой”.

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

@DataJpaTest // slice-тест репозиториев
@Import(SharedPostgresContainerConfig.class) // подключаем тестовую конфигурацию с контейнером
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // не даём Boot подменить DataSource
class ArticleRepositoryPostgresContainerTest {
    // Здесь может быть набор тестов репозитория: контейнер уже подключён через @Import + @ServiceConnection
}

Очень важно не “подключать контейнер всем подряд”. Это прямой путь превратить быстрый suite в медленную махину. Поэтому мы держим такие импорты точечно: только те тесты, которые реально заслуживают real DB path. И имя класса мы тоже делаем честным: ...PostgresContainerTest, чтобы стоимость теста была заметна по названию.

7. Выбор field-based vs bean-based

Когда в тестировании появляются два способа сделать одно и то же, у людей обычно включается внутренний “сектант”: кто-то начинает любить field-based, кто-то — bean-based. Но нам нужен не культ, а рабочая инженерная привычка: выбирать подход под задачу, а не под настроение. Оба варианта нормальные, просто они решают разные организационные проблемы.

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

Критерий Field-based (@Testcontainers + @Container) Bean-based (@TestConfiguration + @Bean)
Где живёт setup Прямо в тестовом классе, максимально явно В конфиге, подключается через @Import
Переиспользование Копипаста или ручная абстракция Естественное, централизованное
“Читается с одного экрана” Обычно да Иногда нужно сделать шаг к конфигу
Риск рассинхронизации версии образа Выше (если копируете) Ниже (один источник правды)
Удобство для малого числа тестов Отлично Тоже нормально, но чуть больше “обвязки”
Удобство для 3+ контейнерных тестов Начинает раздражать Начинает окупаться

Если вы находитесь на стадии “у нас ровно один контейнерный тест, просто хочу попробовать”, field-based вариант чаще всего проще и честнее. Если вы уже отобрали небольшой container-based subset и хотите, чтобы он жил долго и аккуратно, bean-based конфигурация часто делает suite более поддерживаемым.

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

8. Типичные ошибки с @ServiceConnection + PostgreSQL Testcontainers

Ошибка №1: не фиксировать версию PostgreSQL образа (или фиксировать её “плавающе”).
Когда вы пишете "postgres:16-alpine", вы фиксируете major-версию, но minor/patch может “плыть”. Это иногда нормально для учебного проекта, но в рабочем коде лучше фиксировать версию максимально конкретно, чтобы тесты не меняли поведение “сами по себе”. Контейнер — инструмент уверенности, а не генератор сюрпризов.

Ошибка №2: забыть @AutoConfigureTestDatabase(replace = NONE) в @DataJpaTest.
Это классическая ловушка: вы уверены, что тест идёт в контейнер, а Boot тихо подменил datasource на embedded, и вы гоняете тест не там, где думаете. Обычно это всплывает, когда вы начинаете проверять PostgreSQL-специфичное поведение и внезапно получаете “странные” результаты. Для @DataJpaTest правило простое: контейнерная база → replace = NONE.

Ошибка №3: сделать контейнер не static и случайно запускать PostgreSQL на каждый тест-метод.
Технически всё будет работать. Практически — вы будете ждать тесты так долго, что успеете передумать становиться программистом, уйти в бариста и вернуться обратно. Контейнер — дорогой ресурс; если он живёт на уровне класса, он должен быть static.

Ошибка №4: смешать @ServiceConnection и ручные overrides datasource “на всякий случай”.
Например, поставить @ServiceConnection, а потом ещё прописать spring.datasource.url где-нибудь в properties атрибуте или в application-test.yml. В результате вы получаете конфигурационный “пирог”, который трудно объяснить: что реально победило и почему. Если вы выбрали preferred-path, дайте ему работать чисто. Ручной путь имеет смысл только как осознанное исключение.

Ошибка №5: поднять контейнер, но не проверять DB state — остановиться на “HTTP 200”.
Контейнерный тест стоит дорого, и если он не доказывает ничего сверх дешёвых тестов, то это не “интеграционное тестирование”, а “интеграционное самоуспокоение”. Проверяйте наблюдаемый эффект: уникальность slug, порядок выборки, реально записанные timestamps, изменённый статус статьи, конфликт версий. Иначе контейнер просто красиво гудит в фоне.

1
Задача
Spring Test, 25 уровень, 1 лекция
Недоступна
Field-based PostgreSQL container через `@ServiceConnection`
Field-based PostgreSQL container через `@ServiceConnection`
1
Задача
Spring Test, 25 уровень, 1 лекция
Недоступна
Bean-based PostgreSQL container через `@TestConfiguration` и `@Import`
Bean-based PostgreSQL container через `@TestConfiguration` и `@Import`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ