Testcontainers in Spring Boot: why we stopped mocking the database in integration tests
For a long time, the persistence layer in our Spring Boot integration tests lived behind a mock. @MockBean on the repository, Mockito.when(...) returning whatever entity the test needed, and the test ran in milliseconds without touching a real database. That worked fine as long as the query was simple: a findById returning an entity or an Optional.empty(). The trouble started with everything else: a JPQL query with a JOIN FETCH, a UNIQUE constraint that should throw a specific exception, a @Version field for optimistic locking, a hand-written window function in native SQL. None of that runs against a mock, because a mock has no database engine behind it — it just returns whatever we told it to return, regardless of whether that query would even compile against a real schema.
The real problem with repository mocks
A mock verifies that the code calling the repository does the right thing with whatever result the repository hands back. It does not verify that the repository, as actually written, produces that result against a real database. That distinction looks minor until someone edits a @Query and breaks the JPQL syntax, or changes a column type and a conversion that used to work stops working, or adds an integrity constraint that now rejects data the test kept inserting without complaint. With mocks, every one of those changes still passes in green. The failure shows up in production, at the worst possible time: once real data is already involved.
Before Testcontainers, the usual workaround was an in-memory H2 instance running in Postgres compatibility mode. That solved part of the problem — at least a real SQL engine was executing the queries — but the compatibility mode is never complete. Types like jsonb, Postgres-specific functions, the exact behavior of a CHECK constraint, or simply a window function with slightly different syntax produced tests that passed against H2 and failed against the real production Postgres. We had traded mocking the database for mocking the database engine, which is the same problem wearing a different name.
What Testcontainers actually does differently
Testcontainers doesn't simulate anything: it spins up a Docker container running the actual engine image we use in production, runs the migrations against that container, and the test executes real queries against a real Postgres. The difference from pointing tests at a shared development database is that the container is ephemeral and isolated — it's created when the suite starts, every test begins from a known state, and it's torn down when the run finishes, leaving nothing behind to contaminate the next run and removing any dependence on nobody having left dirty data in a shared schema.
The JUnit 5 integration is declared with an annotation on the test class:
@SpringBootTest
@Testcontainers
class PedidoRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@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);
}
@Autowired
private PedidoRepository pedidoRepository;
@Test
void savesAndRetrievesAnOrderWithItsLines() {
Pedido pedido = new Pedido(...);
pedidoRepository.save(pedido);
Pedido retrieved = pedidoRepository.findByIdConLineas(pedido.getId())
.orElseThrow();
assertThat(retrieved.getLineas()).hasSize(2);
}
}
@DynamicPropertySource solves a concrete problem: the container's port isn't known until Testcontainers actually starts it, so the connection properties can't be hardcoded into a static application-test.yml. Registering them dynamically after the container is already running guarantees Spring connects to the real port Docker assigned, not to a fixed one that some other process might already be using.
With this setup, a broken JOIN FETCH fails the test with the same Hibernate error it would throw in production, a UNIQUE constraint throws the same DataIntegrityViolationException, and a native SQL window function runs against the exact engine that will interpret it in production. The test stops asking "does the code call the repository correctly?" and starts asking "does this query actually work?" — which is the question that matters for the persistence layer.
What didn't work: a fresh container per test class
The first version of this migration started a new container per test class, using the default @Testcontainers lifecycle: start when the class begins, tear down when it ends. With two or three integration test classes, the cost was tolerable. The problem showed up as the number of classes grew: each one paid the full cost of booting Postgres from scratch — creating the container, waiting for the engine to accept connections, running the Flyway migrations from zero — before a single assertion ran. The integration suite became by far the slowest part of the CI pipeline, and the time didn't scale with the number of tests inside each class, it scaled with the number of classes, because every class repeated the same startup.
The fix was to share a single container across every integration test class, using the singleton container pattern: a base class or interface that starts the container exactly once, statically, and keeps it running for the whole suite instead of restarting it per class.
public abstract class PostgresContainerBase {
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
static {
postgres.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);
}
}
Every test class extends PostgresContainerBase instead of declaring its own @Container. The container starts once for the whole test JVM, and Testcontainers stops it automatically at the end of the process through its Ryuk mechanism, with no manual shutdown to manage. The cost of booting the engine and running the migrations is paid exactly once per full suite run, not once per class.
That introduced a different problem: if one test leaves data behind and the next one assumes an empty table, execution order starts to matter, which is exactly the kind of coupling between tests we want to avoid. We fixed it with explicit cleanup before each test instead of relying on per-container isolation, using @Sql with a truncation script or a @BeforeEach that clears the relevant tables. That's more code than "every test gets a clean database by definition," but it's a cost we'd rather pay explicitly than discover as a test that only fails when it runs after another one.
The CI time cost you have to accept
Even with a shared container, an integration suite running against Testcontainers is slower than the same suite running against mocks, and there's no point pretending otherwise. Starting Docker, pulling the image if it isn't cached, booting the engine, and running the migrations all add a fixed cost at the start of the run that a mock simply doesn't have. In a CI pipeline where the Postgres image is already in the Docker layer cache, that cost shrinks to the engine startup time plus migrations, which is still greater than zero but stops being the dominant bottleneck as long as the number of integration tests per run stays reasonable.
The decision we made was about scope, not all-or-nothing: unit tests that verify pure business logic still use mocks, because a mock is the right tool there and there's no real database to query in the first place. We reserve Testcontainers for tests that exist specifically to verify that a query, a migration, or a schema constraint works against the real engine. That split keeps most of the suite fast and concentrates the container cost exactly on the tests where that cost buys us something a mock can't.
When mocking is still the right call
Testcontainers doesn't replace every mock in the project, and it shouldn't. If an application service orchestrates three repositories and what we're actually testing is the order those calls happen in and how it handles the exceptions each one throws, mocking those repositories is still correct: the test doesn't need a real Postgres to verify call order and exception handling, and forcing one in would only add time without adding confidence. The question we use to decide is simple: if this query or this constraint ran against the real engine, could it fail in a way the mock can't reproduce? If yes, it goes with Testcontainers. If no — because the test doesn't depend on any engine-specific behavior — a mock is still faster and still correct.