A test that passes on H2 and fails on PostgreSQL warns you too late: you found out in production, or the evening before the release. It happens because the two engines differ in SQL dialect, in types, in extensions and in the behavior of transactions. Testcontainers is the Java library that solves the problem in the most direct way: during the test it starts a Docker container with the real database, of the same version as production, and shuts it down at the end. It does not eliminate every difference (configuration, data volume and network remain different), but it eliminates the one due to a different engine. In this article we see how to write the test, the three mistakes that silently make it useless, and how this site's backend avoids them with a container shared by the whole suite.
A warning about the code: all the listings in this article require Docker and a Spring Boot project, and in this working session they were not run. They are "schema to adapt", written against the Testcontainers 1.21.4 API (the version in the backend's pom.xml) and read against the official documentation. The statements about behavior are all anchored to a source listed at the end.
What Testcontainers does and what you need
Testcontainers relies on a container runtime already present on the machine: if the Docker daemon is not running, the test does not start. The advantage is that every developer and every pipeline gets an empty, isolated and reproducible database, without installing PostgreSQL or fixing port 5432: the container exposes a random port and the test asks which one it is. The project needs three test dependencies, all of the same version. The version is declared once in a property, because modules of different versions are a source of hard-to-read errors.
<properties>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<!-- GenericContainer (used for Redis) -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- PostgreSQLContainer -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- @Testcontainers and @Container for JUnit 5 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
The testcontainers module contains the generic classes (GenericContainer, which the backend uses for Redis), postgresql adds PostgreSQLContainer and junit-jupiter brings the annotations for JUnit 5. It does not bring the JDBC driver: that must already be among the project's dependencies. A caveat about versions: the current Testcontainers documentation describes the 2.x series, where the module is called testcontainers-postgresql; the examples in this article use the name from the 1.x series, the backend's. If you start from scratch, read the documentation of the version you choose.
The basic integration test
The listing starts a PostgreSQL 16 and connects it to the application. The lines that matter are four. @Testcontainers activates the JUnit extension that manages the annotated fields. @Container on a static field declares the container and starts its lifecycle. @DynamicPropertySource registers the connection properties as suppliers (postgres::getJdbcUrl): Spring queries them when it builds the context, that is after the container has started, when the random port is known. Finally the method that registers them must be static, as the Spring Framework documentation prescribes.
@Testcontainers // activates the extension that starts the @Container fields
@SpringBootTest
class RegisterUserIntegrationTest {
@Container // without this annotation the container never starts
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource // the method MUST be static
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl); // read at the moment of use
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired RegisterUserUseCase registerUser;
@Autowired UserRepository users;
@Test
void registersAndPersists() {
registerUser.execute(new RegisterUserRequest("new@example.com", "Secret123!"));
assertTrue(users.findByEmail(new Email("new@example.com")).isPresent());
}
}
Before looking at the mistakes, notice what is missing: no written port, no fixed URL, no H2 configuration. Flyway applies the migrations to the empty database when the context starts, so the test also exercises the real schema. This also shows why using @Transactional to clean up the data is not enough when the test goes through the HTTP server: the Spring Boot documentation specifies that with RANDOM_PORT client and server run on separate threads, hence in separate transactions, and what the server writes is not undone by the test's rollback. In this site's backend, the tests that go through HTTP clean up with @Sql scripts run before and after each method.
Three mistakes that make the test useless or slow
- The container without
@Container: the field exists, but nobody starts it. On the first call togetJdbcUrl()an error is expected, because the mapped port is known only after startup (I did not reproduce it: it would require Docker). It is the most frequent mistake of anyone who copies an example without the annotation, or without callingstart(). - The container as an instance field: with
@Containeron a non-staticfield, the container is started and stopped for every test method. It works, but the startup cost is paid at every test. - The hand-written URL:
jdbc:postgresql://localhost:5432/testdbpresupposes a fixed port, whereas the container's is assigned on every run.
The three mistakes have the same root: you forget that the port and the moment of startup are decided by the container, not by the test. The remedy for all of them is the listing above, where every connection value is read from the container at the moment of use. The second point has explicit confirmation in the Testcontainers documentation for JUnit 5: containers on static fields are shared among the methods of the class, started once before the first test and stopped after the last; those on instance fields are started and stopped for every method.
@Testcontainers
class LifecycleExample {
@Container
static PostgreSQLContainer<?> shared = new PostgreSQLContainer<>("postgres:16"); // one start per class
@Container
PostgreSQLContainer<?> perTest = new PostgreSQLContainer<>("postgres:16"); // one start per method
@Test void a() { /* shared and perTest are running */ }
@Test void b() { /* shared is the same as in a(); perTest is a new container */ }
}
The listing shows the two modes side by side, and it is a good way not to get confused: shared is the same object for a() and b(), perTest is new at every method. Use the instance field only if you need a clean state guaranteed at every test and you accept paying for the startup each time. I do not quantify the startup cost: it depends on the machine, the already-downloaded image and Docker, and I have no measurement to report.
Testing only persistence with @DataJpaTest
When you want to test a persistence adapter and not the whole application, @DataJpaTest loads only the data layer and is lighter than @SpringBootTest. There is, however, a trap in the defaults: if an embedded database is on the classpath, @DataJpaTest configures it in place of the one you indicated. The Spring Boot documentation says that to use a real database you must add @AutoConfigureTestDatabase(replace = Replace.NONE). Without it the test passes on H2 and you have lost the whole point of the exercise. You also need @Import of the adapter, because @DataJpaTest does not scan the normal @Component classes; for the structure of the adapter see JPA and Flyway in Clean Architecture.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // do not replace with H2
@Import({UserJpaAdapter.class, UserJpaMapper.class}) // @DataJpaTest does not scan @Component
@Testcontainers
class UserJpaAdapterIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired UserJpaAdapter adapter;
@Test
void savedUserIsFoundByEmail() {
User saved = adapter.save(User.register(new Email("u@example.com"), "hash"));
assertEquals(saved.id(), adapter.findByEmail(new Email("u@example.com")).orElseThrow().id());
}
}
This test is the most useful one for the infrastructure layer: it shows that the adapter respects the port's contract and that the mapping between domain and entity works with the real columns and types. The business rule, on the other hand, should not be tested here: it has a unit test with no database. See Complete testing in Clean Architecture for how to divide the two.
The container shared by the whole suite
With dozens of integration test classes, starting one container per class becomes the dominant cost. This site's backend solves it with the singleton container pattern, described in the documentation on manual lifecycle control: an abstract base class starts the containers in a static block, and all the test classes extend it. The container starts only once, when the base class is loaded, and at the end of the suite it is stopped by the Ryuk service container that Testcontainers starts alongside.
// IntegrationTestBase.java (excerpt from this site's backend)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT /* , properties = {...} */)
@ActiveProfiles("integration")
public abstract class IntegrationTestBase {
static final PostgreSQLContainer<?> POSTGRES;
static final GenericContainer<?> REDIS;
static {
POSTGRES = new PostgreSQLContainer<>(
DockerImageName.parse("postgis/postgis:16-3.5").asCompatibleSubstituteFor("postgres"))
.withDatabaseName("portfolio_test")
.withUsername("portfolio")
.withPassword("test");
POSTGRES.start();
REDIS = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
REDIS.start();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.data.redis.host", REDIS::getHost);
registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
}
}
Three details of the listing are choices, not conventions. The image is postgis/postgis:16-3.5 declared compatible with postgres through asCompatibleSubstituteFor, because a migration uses the PostGIS extension and with the standard image it would fail (the comment in the source says so). There is neither @Testcontainers nor @Container: the comment in the source explains that the JUnit extension would restart a static container for every concrete subclass, creating several Spring contexts tied to different ports, with connection-refused errors. And Redis is a GenericContainer with port 6379 exposed, read with getMappedPort; the property in Spring Boot 3 is spring.data.redis.host, not the old spring.redis.host.
// ArticleIntegrationTest.java (excerpt from this site's backend)
@SqlGroup({
@Sql(scripts = "/sql/seed-articles.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(scripts = "/sql/clean-articles.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
})
class ArticleIntegrationTest extends IntegrationTestBase {
@Test
void listArticles_returnsPaginatedResult() {
ResponseEntity<Map> response = restTemplate.getForEntity("/api/v1/articles", Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// ... the body contains items, total, page, size, totalPages ...
}
}
The concrete test inherits everything: no container annotation, a TestRestTemplate already configured on the random port, data inserted and cleaned up with @Sql. The price of the singleton is shared state: since the database lives for the whole suite, a test that forgets to clean up contaminates the following ones, and that is why every integration class in the backend has its own cleanup script. In the repository the files with @Tag("integration") are 105 (measured on 2026-11-12 with grep -rl '@Tag("integration")' backend/src/test | wc -l).
Real database, fake external services
An integration test must not send a real email or call a payment gateway. You keep the real database and replace only the external boundary with a mock. In Spring Boot 3.4 the annotation for this is @MockitoBean: @MockBean is deprecated since 3.4 in favor of the Spring Framework one, and the release notes wiki warns that the two are not equivalent (@MockitoBean is not used on @Configuration classes). In this site's backend 55 test files still use @MockBean and 5 use @MockitoBean (measured on 2026-11-12 with grep -rl on the two annotations): the migration has started, not finished.
@SpringBootTest
@Testcontainers
class MockedEmailIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@MockitoBean EmailSender emailSender; // the database is real, the external service is not
@Autowired RegisterUserUseCase registerUser;
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void sendsWelcomeEmail() {
registerUser.execute(new RegisterUserRequest("user@example.com", "pwd"));
verify(emailSender).sendWelcome(any());
}
}
Here the real container serves to test the user's persistence and the mock isolates the sending channel: the test verifies that the use case asks for the email to be sent, not that an SMTP server responds. The same approach applies to any port toward the outside.
Frequently asked questions
Does Testcontainers require Docker?
It requires a compatible container runtime that is running on the machine executing the tests, Docker included. If it is missing, the integration tests do not start: in this site's backend they are therefore separated from the unit tests with a label (@Tag("integration")) and run by selecting that group.
Is @DynamicPropertySource or @ServiceConnection better?
Spring Boot offers @ServiceConnection on a container field, which wires the supported services without listing the properties. The Spring Boot documentation presents @DynamicPropertySource as the "a bit more verbose but more flexible" alternative: it is needed for properties that have no connection service, such as a custom URL.
Can I test the Flyway migrations?
Yes, and the base test already does: Flyway runs at context startup on the empty database, and a broken schema makes the test fail. In the backend the test configuration adds a migration folder used only in tests, to insert a user before a migration that requires one.
What you can do now
You can write an integration test that uses the real database with no hand-written ports, and you can recognize the three mistakes that make it mute or slow: the container never started, the one restarted for every method, the fixed URL. You know when @DataJpaTest fools you with H2 and how to prevent it, why test transactions do not cover the HTTP server, and when to move to a container shared by the whole suite. If you want the overall picture of the test levels, the next step is Complete testing in Clean Architecture; for the persistence model you are testing, JPA and Flyway.
Sources
- Testcontainers - JUnit 5 integration - shared static containers, instance containers restarted for every method; consulted on 2026-11-12
- Testcontainers - Manual container lifecycle control - singleton container and the role of Ryuk; consulted on 2026-11-12
- Spring Boot Reference - Testcontainers -
@ServiceConnectionand@DynamicPropertySource; consulted on 2026-11-12 - Spring Boot Reference - Testing Spring Boot Applications -
@DataJpaTest,Replace.NONE, transactions withRANDOM_PORT; consulted on 2026-11-12 - Spring Framework - @DynamicPropertySource - the method must be
static; consulted on 2026-11-12 - Spring Boot 3.4 Release Notes - deprecation of
@MockBean; consulted on 2026-11-12 - Backend sources:
IntegrationTestBase.java,ArticleIntegrationTest.java,application-integration.ymlunderbackend/src/test,pom.xml- read on 2026-11-12

Commenti
Caricamento commenti...
Sign in to comment