CodeGym /Courses /Spring Data JPA /Testcontainers: PostgreSQL in service tests

Testcontainers: PostgreSQL in service tests

Spring Data JPA
Level 28, Lesson 3
Available

1. Testcontainers vs Docker Compose

If you have already started PostgreSQL through Docker Compose, the question “why another Docker?” is completely normal. It seems that you could simply run tests against a local database — and everything would be fair. The problem is that each student’s local database (and CI) is a small universe with its own settings, leftover data, and “oops, I forgot to apply the migrations”.

Testcontainers solves this very pragmatically: the test itself starts its own isolated PostgreSQL, gives Spring the connection parameters, and shuts it down afterwards. In other words, the test stops depending on what is currently running in Docker Desktop, what data remained after yesterday’s experiments, or whether someone changed a port in .env. For service tests, this is especially important because they verify not one query, but an entire scenario, where “a tiny database mismatch” turns into “why does my order sometimes get created and sometimes not”.

It is useful to capture this in a simple table, like a “decision matrix”, rather than a list of rules:

Approach What we run Pros Cons When it fits
H2 (in-memory) Nothing external Very fast Not PostgreSQL, may hide problems Fast signal for simple checks when PostgreSQL specifics are not important
Local PostgreSQL via Compose Manually/scripted Real PostgreSQL Not isolated, depends on the environment Local development, manual checks, application startup
PostgreSQL via Testcontainers Started by the test Isolated, closer to production, great for CI Slower, requires Docker Service integration tests, migrations, constraints, optimistic locking, native SQL

2. What Testcontainers is: “JUnit → Docker → PostgreSQL → Spring”

Testcontainers is easy to think of as “a library that makes containers”, but that is too vague. It is much more useful to think like this: a JUnit test gets an external resource dependency, and Testcontainers takes care of that resource’s lifecycle. In other words, it starts the container before the tests, provides the connection parameters, and guarantees that the container will be stopped correctly.

Below is a diagram that helps beginners avoid confusion about who starts what. There is no “Spring magic” here, only a sequence of steps:

flowchart TD
  T[Test method starts in JUnit 6] --> TC["@Testcontainers manages the container"]
  TC --> D[Docker starts postgres image]
  D --> P[(PostgreSQL in the container)]
  T --> S[Spring Boot Test Context starts the application]
  S -->|reads datasource properties| P
  S -->|Flyway applies migrations| P
  T -->|calls services| S

Pay attention to an important point: Spring Boot does not “start Docker”. Docker is started by Testcontainers, while Spring Boot simply receives ordinary DataSource parameters (url, username, password) and works as if this were “a regular PostgreSQL somewhere nearby”. That is exactly why @DynamicPropertySource is the key bridge: it connects the container world with the Spring configuration world.

And yes, this relies on Docker. So if Docker is not running, Testcontainers will complain honestly. No miracles: containers do not start without Docker, just like a kettle without electricity.

3. Add Testcontainers to Gradle and prepare the project

Before writing test code, you need to do two boring but important things: add dependencies and make sure the test environment can actually start containers. Dependencies are simple: we need the Testcontainers module for JUnit 6 and the module for PostgreSQL. It is important that these dependencies are testImplementation, because Testcontainers is not needed at application runtime. That setup is enough: from there, service tests simply use it instead of inventing a new configuration for each class.

For shop-data-jpa (Gradle), this usually looks like this:

dependencies {
    // Basic test set for Spring Boot (JUnit 6 inside the starter)
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // Testcontainers integration with JUnit 6 (Jupiter API)
    testImplementation("org.testcontainers:junit-jupiter")

    // PostgreSQL container for tests
    testImplementation("org.testcontainers:postgresql")
}

If you already have spring-boot-starter-test connected, then you only add two lines. Next, remember the infrastructure reality: Testcontainers starts a Docker container, so Docker must be installed and running on your machine (on Windows/macOS, usually Docker Desktop). This is not a “Spring setting”; it is simply a condition of the game.

Another important nuance for our course: if your project already has Flyway and migrations in db/migration, then when @SpringBootTest starts on a clean container database, Flyway will apply the migrations automatically. That is a huge plus: you test services on a schema that matches the project, not on “whatever happens to work”.

4. Start a PostgreSQL container in JUnit 6: @Testcontainers and @Container

Now we get to the fun part: what containerized PostgreSQL looks like in a test class. It is important not to mix up the two roles. The @Testcontainers annotation tells JUnit 6: “this test class has containers, and Testcontainers manages their lifecycle”. The @Container annotation marks a specific container field: “this is the container that needs to be started/stopped”.

A minimal container skeleton looks like this:

import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
class PostgresContainerBaseTest {

    // The container is started once per class (faster, but data discipline matters)
    @Container
    static PostgreSQLContainer
   postgres =
            new PostgreSQLContainer<>("postgres:17") // The image version is fixed to avoid surprises
                    .withDatabaseName("shop")
                    .withUsername("shop")
                    .withPassword("shop");
}

The word static is important here. If the container is static, it starts once for the whole class (usually faster). If the container is not static, it starts for each test method (cleaner for isolation, but noticeably slower). For service integration tests in a learning project, a reasonable default is static, and data cleanliness can be ensured either by careful setup or explicit cleanup.

And yes, postgres:17 is a regular Docker image. There is no such thing as a “special PostgreSQL for tests”: it is normal PostgreSQL, just temporary.

5. Pass parameters into Spring Boot: @DynamicPropertySource

The container itself will not help if Spring Boot keeps reading DataSource settings from application-test.yml and tries to connect to the old address. Our job is to tell Spring: “take the URL, login, and password from the container”. This is where @DynamicPropertySource is used: it is a way to programmatically add properties to the Spring Environment before the context starts.

It looks like this:

import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
    // The URL actually includes the random port assigned by Docker
    registry.add("spring.datasource.url", postgres::getJdbcUrl);

    // We also take the login/password from the container to avoid config mismatch
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

Pay attention: the method must be static. This is one of those tiny details that break everything “for no apparent reason” if you miss it. The logic is simple: Spring must receive these properties before the beans are created, so it cannot wait until it has an instance of the test class.

Another pragmatic point. Sometimes application-test.yml still contains old settings such as spring.datasource.url=jdbc:h2:mem:testdb. With @DynamicPropertySource, they will be overridden, but it is better to keep the test config clean so you do not have to guess what is actually being applied now. In tests, transparency helps everyone: both you and the future team.

6. A service integration test on PostgreSQL

When the container is up and Spring has connected to it, the service test looks almost the same as a regular @SpringBootTest. This is an important psychological point: you do not need to “rewrite tests for containers”. You simply change the database source to a more realistic one.

Below is an example skeleton test that lives in the shop-data-jpa context and checks that the context starts and the service is available:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class OrderServicePostgresIT extends PostgresTestBase {

    // We get the bean from the context: a useful minimum to understand that the app really started
    @Autowired
    OrderService orderService;

    @Test
    void contextStarts() {
        // Educational check: the service started and was injected through DI
        org.junit.jupiter.api.Assertions.assertNotNull(orderService);
    }
}

For this to work, we need a base abstraction called PostgresTestBase, where the container and @DynamicPropertySource live. It is convenient to keep it in the test package com.example.shopdatajpa.testinfra or nearby, so that you do not copy the same thing into every test.

Example of such a base:

import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
abstract class PostgresTestBase {
    // Base for all integration tests:
    // - declare the postgres container here
    // - pass properties through @DynamicPropertySource here as well
}

Now let us add a small but very useful diagnostic: let us make sure we really connected to PostgreSQL, and did not accidentally end up on H2 again because of configuration.

import javax.sql.DataSource;
import java.sql.Connection;

@Autowired
DataSource dataSource;

@Test
void usesPostgreSql() throws Exception {
    // Connect to the DataSource assembled by Spring (important: not to the container directly)
    try (Connection c = dataSource.getConnection()) {
        String db = c.getMetaData().getDatabaseProductName();
        System.out.println("DB = " + db); // We expect to see: DB = PostgreSQL
    }
}

This is not a “real assert”, but as a learning step it is very useful. If you saw H2, then the container did not connect, and the test is not about that at all.

And now a bit closer to the meaning of service tests: a use-case check. A simplified example: create test data with repositories, call placeOrder(), then read the data again and verify the result. The form of the check matters here: the service runs a transactional scenario, and the repositories act like “sensors” to measure the final state in the database.

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;

@Test
void placeOrderCreatesOrder() {
    // Prepare input data: product + stock quantity
    Long productId = testData.createProduct("SKU-900", new BigDecimal("10.00"));
    testData.setStock(productId, 5);

    // Action: call the business scenario at the service level
    Long orderId = orderService.placeOrder(productId, 2);

    // Verify the result: reread the entity from the DB (the repository acts like a "sensor")
    CustomerOrder order = orderRepository.findById(orderId).orElseThrow();
    org.junit.jupiter.api.Assertions.assertEquals(2, order.getItems().size());
}

In this fragment, I intentionally use testData as a learning abstraction, because real order data preparation usually takes more than 10 lines. The point is different: you do not have to “write SQL by hand” for setup. You can prepare data through repositories and run the use case through the service.

And one more important nuance that is directly related to today’s topic. If you are testing rollback, it is especially important to reread data from repositories after the exception, not to look at already loaded entities. The containerized PostgreSQL does not “fix” the problem here, but it makes the test more honest: rollback and constraints behave as closely as possible to the project database.

7. When Testcontainers is really needed

When someone first sees Testcontainers, they may want to say: “Oh, great! Now all tests will run on PostgreSQL, and we will forget about surprises forever.” That desire is understandable. And dangerous — primarily because of speed: containers start slower, the context starts slower, and in the end tests begin to “punish” you for every change.

A healthy strategy for our course is this. Narrow repository tests stay in @DataJpaTest, because they provide a fast signal and verify very specific things. Service integration tests, where transactions, rollback, constraints, optimistic locking, and migrations matter, are good candidates for Testcontainers. The result is not “everything in containers”, but “containers where they provide new information”.

It is also useful to keep a simple engineering formula in mind: Testcontainers does not add new assertions for you. It only makes the environment closer to PostgreSQL truth. If your test is already weak (it only checks assertNotNull(service)), the container will not make it smart. But if the test really checks a business invariant and transactional behavior, the container adds confidence that you verified it in the correct database.

8. Typical mistakes when working with Testcontainers

Mistake #1: Docker is not running, and we expect “Spring will somehow handle it”.
The most common reason container tests fail is trivial: Docker is off or unavailable (especially after a system update). It is important to accept this: Testcontainers is not a Java dependency, it is a bridge to Docker. If Docker is unavailable, the tests cannot start the database, and that is normal.

Mistake #2: the container started, but Spring still connected to the wrong place.
This happens when you forget @DynamicPropertySource, set the spring.datasource.* keys incorrectly, or leave something in the test profile that overrides the properties later. The symptom looks like “the connection succeeds, but the database is not Postgres”. In a learning project, it is useful to have one diagnostic test that prints DatabaseProductName.

Mistake #3: forgetting that @DynamicPropertySource must be static.
This mistake is especially annoying for beginners: the code looks correct, the container is declared, but the properties are not applied. The reason is almost always that the method is not static (or the container is declared in the wrong scope). The discipline here is simple: the container and the properties method are static, so that Spring can use them before the context starts.

Mistake #4: “if there is a container, the database is clean” (but it is not).
If the container is static, the database lives for the whole test class. That means data created by one test can affect the next one. Sometimes this does not show up immediately, but only “on the third run”. The fix is not magic, but discipline: either clean up data carefully in @BeforeEach (in the right order because of FK), or prepare data thoughtfully so the tests do not conflict.

Mistake #5: turning a container test into an encyclopedia of checks.
A temptation appears: since the test is slow and honest, let us put everything into it — rollback, migrations, optimistic locking, and one more reporting query. In the end, when it fails, you do not understand what exactly broke. A container test, like any good test, should verify one main promise of the use case, just on a more realistic database.

1
Task
Spring Data JPA, level 28, lesson 3
Locked
Container PostgreSQL for Starting the Spring Context
Container PostgreSQL for Starting the Spring Context
1
Task
Spring Data JPA, level 28, lesson 3
Locked
Service Test with PostgreSQLContainer
Service Test with PostgreSQLContainer
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION