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

PostgreSQL через @ServiceConnection

Spring Test
Рівень 25 , Лекція 1
Відкрита

1. Preferred-path і @ServiceConnection

Коли розробник уперше чує «Testcontainers», йому часто здається, що зараз він напише 40 рядків конфігурації й переможе інфраструктуру. Працює, але зазвичай це шлях до копіпасти, неочевидних перевизначень властивостей і тестів, які складно пояснити вже за кілька тижнів. Preferred-path у Boot 4 — це навпаки: мінімум ручної роботи й максимум читабельного змісту.

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

Ідея @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[Автоконфігурація створює DataSource]
    E --> F[Flyway/JPA працюють із реальним PostgreSQL]
    F --> G[Тест виконує сценарій і перевіряє стан БД]

Найважливіше відчуття, яке потрібно винести: @ServiceConnection — це наче Spring Boot сам написав за вас той код, який ви б написали в @DynamicPropertySource, але зробив це коротше, уніфікованіше й із меншою кількістю місць для «ой, я забув пароль».

І так, саме тому @ServiceConnection — preferred-path. Він робить те саме, що й ручна реєстрація властивостей, але зазвичай швидше пишеться, простіше читається й краще підтримується. А вже в наступній лекції ми подивимося, що робити, коли автоматичного шляху з якихось причин недостатньо. Але поки що залишаємося у світі простих перемог.

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

Дуже корисно побачити, що один і той самий спосіб підключення контейнера працює не лише для @DataJpaTest, а й для повного контексту. Бо контейнер — це не «анотація для репозиторіїв», а просто спосіб дати застосунку реальну базу даних у тесті. А який рівень тесту ви обрали — slice чи full-context — це вже окрема інженерна вісь.

Форма тесту тут інша, а спосіб підключення бази — той самий. Якщо конкретний ризик не можна довести на рівні репозиторію, той самий контейнер спокійно переїжджає в @SpringBootTest.

У курсі ми постійно тримаємо в голові, що контейнер — дорогий інструмент, тому використовуємо його точково. Але якщо ви вирішили, що конкретний наскрізний сценарій має бути перевірений саме на PostgreSQL, наприклад тому, що там важливі міграції, мітки часу або обмеження на рівні БД, то @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, і це вже буде повноцінний міжшаровий тест із реальною базою. Він дорогий. Тому ми робимо його свідомо, а не «бо можемо».

Як не зробити 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 класів уже кілька, таке винесення починає окуповуватися досить швидко.

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 і ручні перевизначення datasource «про всяк випадок».
Наприклад, поставити @ServiceConnection, а потім ще прописати spring.datasource.url десь у properties або в application-test.yml. У результаті ви отримуєте конфігураційний «пиріг», який важко пояснити: що реально перемогло і чому. Якщо ви обрали preferred-path, дайте йому працювати чисто. Ручний шлях має сенс лише як свідомий виняток.

Помилка №5: підняти контейнер, але не перевіряти стан БД — зупинитися на «HTTP 200».
Контейнерний тест коштує дорого, і якщо він не доводить нічого понад дешеві тести, то це не «інтеграційне тестування», а «інтеграційне самозаспокоєння». Перевіряйте спостережуваний ефект: унікальність slug, порядок вибірки, реально записані мітки часу, змінений статус статті, конфлікт версій. Інакше контейнер просто красиво гуде на тлі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ