CodeGym /Kursy /Spring Test /PostgreSQL via @ServiceConnection

PostgreSQL via @ServiceConnection

Spring Test
Poziom 25, Lekcja 1
Dostępny

1. Preferred path and @ServiceConnection

When a developer first hears “Testcontainers,” they often imagine a level-one plan like, “I’ll write 40 lines of configuration and beat the infrastructure.” It works, but usually that path leads to copy-paste, non-obvious property overrides, and tests that are hard to explain a couple of weeks later. The preferred path in Boot 4 is the opposite: minimum manual work and maximum readable meaning.

Once it is clear that the risk really comes down to PostgreSQL, the next problem is not philosophical at all: how do you connect a container so the test does not turn into a warehouse of manual spring.datasource.* values? For the standard datasource scenario, you want the shortest possible path, and that is exactly where @ServiceConnection removes the extra plumbing.

The idea behind @ServiceConnection is very simple, and that is what makes it great: you tell Spring Boot, “here is the container, and this is my test database,” and Boot itself extracts the connection parameters from the container and injects them into the environment. In other words, you stop being the person who manually carries the JDBC URL through the test, and you become the person who writes tests. Strangely enough, that is much closer to real life.

If we reduce it to one sentence, @ServiceConnection is a built-in Spring Boot container → connection settings adapter that saves you both time and the number of places where you can make a mistake. And we do not like mistakes in tests for the same reason we do not like bugs in production: they ruin the mood, and mood is an important part of engineering.

Minimal Testcontainers dependencies

Before we can connect the container nicely with an annotation, we need to do the boring thing: make sure the project actually has Testcontainers classes and Spring Boot integration with containers. This is the moment where a beginner often says, “but I already have spring-boot-starter-test,” and then wonders why PostgreSQLContainer does not import. The starter is good, but it is not telepathic.

In ContentHub, our basic idea is this: Testcontainers is a test-only dependency, and we need a “double” set: Testcontainers itself (and the PostgreSQL module), plus spring-boot-testcontainers, so Boot understands @ServiceConnection and can automatically wire the container to datasource auto-configuration.

Example for build.gradle.kts, intentionally short and “without magic”:

dependencies {
    testImplementation("org.springframework.boot:spring-boot-starter-test") // JUnit 6, Spring Test, AssertJ, etc.
    testImplementation("org.springframework.boot:spring-boot-testcontainers") // support for @ServiceConnection in tests
    testImplementation("org.testcontainers:junit-jupiter") // Testcontainers integration with JUnit 6 (Jupiter API)
    testImplementation("org.testcontainers:postgresql") // PostgreSQLContainer and PostgreSQL-specific support
}

Pay attention to an important nuance: versions in the course are controlled by the Boot stack (we do not assemble a “zoo” manually), so in a real project you usually either do not write versions at all, or only write them where your dependency management did not pick up what it needed. In the educational project, we keep this aligned through the course’s shared platform stack — and that is exactly what makes such integration reproducible.

3. Field-based container in @DataJpaTest

The most direct scenario for ContentHub is repository tests that we deliberately decided to run against a real PostgreSQL. And here you want the test to read like this: “this is a @DataJpaTest, but the datasource comes from a container.” The field-based style makes this as obvious as possible: the container lives right in the test class as a field, and JUnit manages its lifecycle.

The main advantage of this option is that you can immediately see where the test infrastructure is hidden, without jumping through configuration classes. The downside is honest too: if you start copying this field into 10 tests, the infrastructure will begin to spread. So in this lecture we will show both the straightforward version and the reuse-friendly version via @TestConfiguration (a bit later).

Skeleton of ArticleRepositoryPostgresContainerTest

Let us start with the thing people usually want to do first: bring up the container and make sure the Spring Data JPA test starts against a real database.

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 // the Testcontainers extension manages containers marked with @Container
@DataJpaTest // slice test: the JPA layer is started (EntityManager, repositories, etc.)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // do not replace DataSource with embedded
class ArticleRepositoryPostgresContainerTest {

    @Container // this container will be started/stopped by Testcontainers
    @ServiceConnection // Boot takes url/user/password from here and configures the DataSource automatically
    static PostgreSQLContainer
   postgres =
            new PostgreSQLContainer<>("postgres:16-alpine"); // PostgreSQL image for tests
}

There are several meaningful things here that are worth saying out loud, otherwise this just looks like “ritual annotations.”

@Testcontainers wires in the JUnit Testcontainers extension, which knows how to start and stop containers marked with @Container. The @Container annotation says, “this specific container object needs to be started.” And @ServiceConnection — the key annotation in this lecture — tells Spring Boot, “use this container as the source of connection parameters.”

And yes, with just this skeleton the test can already start, run migrations, bring up the EntityManager, and be ready to verify the repository. This is one of those cases where “magic” is good magic: less manual configuration means fewer manual mistakes.

static container

If you do not make the container static, it will be created, and potentially started, for every test class instance. And in JUnit 6, a test class instance is usually created for each test method. The result: the container starts living like mayonnaise in the refrigerator — “sometimes it is there, sometimes it is not, and why is everything so slow?”

By making the container static, you are saying, “the container is a class-level resource.” JUnit will start it once per class and stop it afterward. This sharply lowers the cost of running tests, within the class, and makes the behavior more predictable.

To feel the effect, imagine the difference between “start PostgreSQL once and run 10 tests” and “start PostgreSQL 10 times.” In the second case, you are not testing the application — you are testing the developer’s patience. And as practice shows, developer patience does not go through Flyway migrations as briskly as PostgreSQL does.

@AutoConfigureTestDatabase(replace = NONE)

@DataJpaTest has an important feature: by default, Spring Boot likes to replace the datasource with an embedded variant, if one is available in the dependencies, because for many projects that is the fastest path. But in a container-based test we specifically want not to replace it, and instead to use PostgreSQL from the container honestly.

That is why @AutoConfigureTestDatabase(replace = NONE) is here not “for beauty,” but to tell Boot: “do not interfere with your helpfulness; the datasource has already been chosen.” You can think of it as a “do not disturb, I am working” sign on the door, except in annotation form.

4. How Boot processes @ServiceConnection

To avoid treating @ServiceConnection like some magical incantation from a book called “Spring for the Brave,” it helps to keep a simple mental model of what is happening. We are not diving into Boot internals here (otherwise this would become a course called “how to read Boot source code”), but we do need to understand the cause-and-effect chain: why it works and why it is shorter than manual properties.

In the PostgreSQL case, the container starts, gets a dynamic port, and forms a JDBC URL like jdbc:postgresql://localhost:54321/.... The port may be different every time, and that is normal, so you cannot hardcode the URL in application-test.yml. Then Spring Boot sees the @ServiceConnection annotation and automatically registers the connection details in the application environment. After that, the usual datasource auto-configuration does what it does best: it creates a DataSource using those details.

You can picture it like this:

flowchart TD
    A[JUnit + Testcontainers] --> B[Starts PostgreSQLContainer]
    B --> C[Container provides JDBC URL / user / password]
    C --> D["@ServiceConnection: Boot picks up connection details"]
    D --> E[AutoConfiguration creates DataSource]
    E --> F[Flyway/JPA work with real PostgreSQL]
    F --> G[Test executes scenario and verifies DB state]

The most important feeling to take away is this: @ServiceConnection is as if Spring Boot wrote for you the code you would have written in @DynamicPropertySource, but did it shorter, more consistently, and with fewer places for “oops, I forgot the password.”

And yes, that is exactly why @ServiceConnection is the preferred path. It does the same thing as manual property registration, but it is usually faster to write, easier to read, and better to maintain. In the next lecture, we will look at what to do when the automatic path is not enough for some reason — but for now we stay in the world of simple wins.

5. Full context: @SpringBootTest and the container

It is very useful to see that the same container wiring approach works not only for @DataJpaTest, but also for the full context. Because a container is not “an annotation for repositories,” it is simply a way to give the application a real database in a test. Which test level you choose (slice or full context) is a separate engineering axis.

The shape of the test is different here, but the way the database is connected is the same. If a particular risk cannot be proven at repository level, the same container can move over to @SpringBootTest with no problem.

Throughout the course, we keep the idea in mind that a container is an expensive tool, so we use it selectively. But if you decided that a specific end-to-end scenario must be verified against PostgreSQL, for example because migrations, timestamps, or DB-level constraints matter, then @SpringBootTest + a container is perfectly appropriate — there just should not be many of them.

Skeleton of ArticlePublicationFlowPostgresIntegrationTest

Here is the minimal full-context test skeleton, where the container is connected through the same 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 // the Testcontainers extension manages the container lifecycle
@SpringBootTest // full context: the whole ApplicationContext is started
@AutoConfigureMockMvc // if we go through MockMvc, it is already available in the context
class ArticlePublicationFlowPostgresIntegrationTest {

    @Container // the container starts before the context and stops after the class tests
    @ServiceConnection // Boot injects the database connection properties from the container
    static PostgreSQLContainer
   postgres =
            new PostgreSQLContainer<>("postgres:16-alpine"); // PostgreSQL for integration tests
}

It looks almost suspiciously simple, but the meaning is this: Spring Boot starts the full ApplicationContext, the datasource is taken from the container, migrations run, JPA is ready, and you can either call services directly (if the test is not about HTTP), or go through MockMvc/RestTestClient depending on the chosen integration-test mode.

If you add @AutoConfigureMockMvc, you can go through the controller → service → repository chain, and that becomes a full cross-layer test with a real database. It is expensive. So we do it deliberately, not “because we can.”

How not to make an integration test “just HTTP 200”

A very typical problem with integration tests on a real database sounds like this: “we started the container, wrote a test, and it checks status().isOk().” Formally the test is green, but in practice it checks almost nothing: the container does not make the test more useful if you do not verify the observable effect that made the container necessary in the first place.

An example, simplified, where we not only make an HTTP request, but also read state back from the repository:

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;

// Fragment inside 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 level: the request passed

    // DB level: verify the observable effect in persistence
    assertThat(articleRepository.findById(42L).orElseThrow().getStatus())
            .isEqualTo(ArticleStatus.PUBLISHED);
}

Yes, here we have two sets of assertions: HTTP-level (status) and DB-level (article state). And that is normal, because such a test is, by definition, end-to-end. It must prove that the request passed through the application and left a correct trace in persistence.

Notice that we are not checking “which repository methods were called” (that would be interaction testing), and we are not checking “what SQL was generated” (that is another class of task). We are checking behavior: after approve, the article became PUBLISHED. That is the level of “truth” we really need for regression testing.

6. Bean-based: container in @TestConfiguration

The field-based approach is very clear, but it has an obvious problem: once you have several container-backed test classes, you start copying the same piece of code with static PostgreSQLContainer. At some point someone changes the image version in one place and forgets it in another, and you get a “fun” situation: some tests run on PostgreSQL 16, while others run on PostgreSQL 17. And that is not because you intended it, but because copy-paste does not have a sense of responsibility.

The bean-based option solves this by describing the container as a test-only bean in @TestConfiguration, and then you import it wherever it is needed. That makes things cleaner, and reuse becomes manageable.

If there are already several container-backed classes, this extraction starts to pay off fairly quickly.

SharedPostgresContainerConfig

Here is an example of a test-only configuration that declares the PostgreSQL container as a bean and marks it with @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 // will only be applied in the test context
class SharedPostgresContainerConfig {

    @Bean // make the container a managed Spring bean
    @ServiceConnection // Boot automatically takes url/user/password and configures the DataSource
    PostgreSQLContainer
   postgres() {
        // In this style, Spring manages the lifecycle (not JUnit via @Testcontainers/@Container)
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

The idea here is that Spring Boot sees a bean of type PostgreSQLContainer, starts it as a managed resource of the test context, and automatically uses its connection details for the datasource. You do not need @Testcontainers and @Container, because lifecycle management is now handled by Spring, not JUnit.

This style is especially pleasant when you want the container setup to be “one for a group of tests” and to change it centrally. In the ContentHub educational project, that is completely realistic: you pick 2–3 tests against PostgreSQL and want all of them to use the same image and the same container configuration.

Connecting via @Import

Then the needed test simply imports the configuration. As a result, the test looks like a “regular @DataJpaTest, but with a container database.”

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

@DataJpaTest // repository slice test
@Import(SharedPostgresContainerConfig.class) // connect the test configuration with the container
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // do not let Boot replace the DataSource
class ArticleRepositoryPostgresContainerTest {
    // This can contain a set of repository tests: the container is already connected via @Import + @ServiceConnection
}

It is very important not to “connect the container to everyone.” That is a direct path to turning a fast suite into a slow machine. So we keep such imports targeted: only the tests that truly deserve the real DB path. And we keep the class name honest too: ...PostgresContainerTest, so the test cost is visible from the name.

7. Choosing field-based vs bean-based

When testing introduces two ways to do the same thing, people often switch on their inner “sectarian”: some start loving field-based, others bean-based. But what we need is not a cult, but a good engineering habit: choose the approach based on the task, not the mood. Both options are fine; they just solve different organizational problems.

To make the decision easier, it helps to think of it as “local and explicit” versus “centralized and reusable.” Here is a small table that often helps people choose instead of argue:

Criterion Field-based (@Testcontainers + @Container) Bean-based (@TestConfiguration + @Bean)
Where the setup lives Right in the test class, maximally explicit In config, brought in via @Import
Reuse Copy-paste or manual abstraction Natural, centralized
“Fits on one screen” Usually yes Sometimes you need to step over to the config
Risk of image version drift Higher (if copied) Lower (single source of truth)
Convenience for a small number of tests Excellent Also fine, but with a bit more “wrapping”
Convenience for 3+ container tests Starts to get annoying Starts to pay off

If you are at the stage of “we have exactly one container test, I just want to try it,” the field-based option is usually simpler and more honest. If you have already picked a small container-based subset and want it to live long and neatly, bean-based configuration often makes the suite more maintainable.

And one more engineering thought: as soon as you notice that you are arguing about style instead of cost and readability of tests, it is time to pause. Usually that is a sign you have forgotten why you came to testing in the first place. We came to catch regressions, not to compare annotation counts.

8. Common mistakes with @ServiceConnection + PostgreSQL Testcontainers

Mistake #1: not pinning the PostgreSQL image version, or pinning it in a “floating” way.
When you write "postgres:16-alpine", you pin the major version, but the minor/patch can drift. That is sometimes fine for a teaching project, but in production code it is better to pin the version as precisely as possible so the tests do not change behavior on their own. A container is a tool for confidence, not a surprise generator.

Mistake #2: forgetting @AutoConfigureTestDatabase(replace = NONE) in @DataJpaTest.
This is a classic trap: you are sure the test is running in the container, but Boot quietly replaced the datasource with embedded, and you are running the test somewhere else than you think. It usually shows up when you start checking PostgreSQL-specific behavior and suddenly get “strange” results. For @DataJpaTest, the rule is simple: container database → replace = NONE.

Mistake #3: make the container non-static and accidentally start PostgreSQL for every test method.
Technically, everything will work. Practically, you will wait for the tests so long that you may have time to change your mind about becoming a programmer, go work as a barista, and then come back. A container is an expensive resource; if it lives at class level, it should be static.

Mistake #4: mix @ServiceConnection with manual datasource overrides “just in case.”
For example, set @ServiceConnection, and then also define spring.datasource.url somewhere in a properties attribute or in application-test.yml. As a result, you get a configuration “layer cake” that is hard to explain: what actually won, and why? If you chose the preferred path, let it work cleanly. The manual path only makes sense as a deliberate exception.

Mistake #5: bring up the container, but do not check DB state — stop at “HTTP 200.”
A container test is expensive, and if it proves nothing beyond cheap tests, then it is not “integration testing” but “integration self-soothing.” Verify the observable effect: slug uniqueness, fetch order, actually written timestamps, changed article status, version conflict. Otherwise the container just hums nicely in the background.

1
Zadanie
Spring Test, poziom 25, lekcja 1
Niedostępne
Kontener PostgreSQL oparty na polu przez `@ServiceConnection`
Kontener PostgreSQL oparty na polu przez `@ServiceConnection`
1
Zadanie
Spring Test, poziom 25, lekcja 1
Niedostępne
Bean-based kontener PostgreSQL przez `@TestConfiguration` i `@Import`
Bean-based kontener PostgreSQL przez `@TestConfiguration` i `@Import`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION