
Introduction
When building backend applications, one of the trickiest layers to test properly is the data access layer (DAL/Repository). Traditionally, developers rely on mocking frameworks like Mockito to simulate database behavior. But mocking a relational database like PostgreSQL rarely reflects production reality – indexes, constraints, locks, transactions, query planners… none of these exist in a mocked environment.
In modern backend systems, the gap between mocked tests and actual database behavior often leads to subtle bugs only discovered in staging or worse, in production. This is where Testcontainers shines. Testcontainers allows backend developers to run real, lightweight PostgreSQL instances inside Docker during test execution. No need for local installations, no fragile mocks, and no reliance on an external staging DB.
In this article, you’ll learn:
- Why mocking the database is not enough
- How Testcontainers provides real integration testing for backend apps
- How to write DAO tests using Java Spring Boot, JUnit 5 and PostgreSQLContainer
- Best practices (speed, stability, cleanup)
- How to run Testcontainers smoothly in GitHub Actions CI/CD
By the end, you’ll have a clean, reproducible testing setup that significantly improves code quality and confidence. The code snippets in this article are using Java with Spring Boot framework, but Testcontainers supports a variety of programming languages, so do not hesitate to hop in with us!
The Problem with Mocking Databases
Testing DAOs with mocks gives you a false sense of safety.
What mocks don’t catch:
- Unique constraint violations
- Foreign key reference errors
- Transactional behavior (commit/rollback)
- SQL syntax issues
- Query plan inefficiencies
- Missing indexes
- Data type mismatches
- Cascade delete behaviors
- Concurrency conflicts
Imagine this typical scenario:
when(userRepository.save(any())).thenReturn(mockUser);
This tells you nothing about your SQL, schema, or database state. If your SQL query is wrong, mocks won’t help. If you forget to add a foreign key, mocks won’t tell you. If you delete a parent row and the children remain as orphans, mocks won’t catch it.
Real confidence comes from testing against a real database — not a fake one.
What about H2?
For many years, Java developers have relied on H2 as the default in-memory database for integration tests. It’s fast, lightweight, and easy to configure. Spring Boot used to automatically replace your real database with H2 when running tests, making H2 the “convenient option”. The truth is: H2 does not reflect an enterprise-grade database.
They behave differently in dozens of subtle (and not-so-subtle) ways. And those differences can- and do- cause production bugs that go undetected in tests. Here is a simple comparison between H2 and a popular enterprise database, PostgreSQL
| Criteria | H2 | PostgreSQL | Risk |
| Datatypes | Only support basic datatypes | A wide range of datatypes: JSONB, arrays, custom types, ..etc | Precision loss, sometimes even errors |
| Operation | Only basic operations | A lot of handy operations like ILIKE, CTE, REGEX, ..etc | Have to skip or mock the test if the operation is not supported |
| Semantic | Too permissive (ignores type mismatches, auto-casts values, ambiguous SQL, ..etc) | Much more strict | This results in “works on my machine” test suites that break immediately in staging |
Enter Testcontainers: Real Infrastructure for Testing

Testcontainers is a Java library that uses Docker to spin up real infrastructure components during tests:
- PostgreSQL
- Redis
- Kafka
- RabbitMQ
- MinIO
- Localstack
- And many more
In this article, we will focus on using Testcontainers for a PostgreSQL database.
Why Testcontainers is a game-changer
- Uses real PostgreSQL, not in-memory substitutes
- Matches production behavior
- Tests are fully isolated
- The database is fresh for every test
- No need to install PostgreSQL locally
- Automatically spins up and tears down the container
- Works on developer machines and CI pipelines
This brings your integration tests much closer to production reality without complicating your development setup.
Setting Up Testcontainers in a Java Project
Below is a minimal setup using Maven, with JUnit 5.
Maven Dependencies
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
<!-- BOM to keep Testcontainers versions aligned -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
JUnit 5 Dependency (if not already included)
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
Also, add this to your pom.xml to properly separate unit and integration tests:
<build>
<plugins>
<!-- Unit Tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<excludes>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
</plugin>
<!-- Integration Tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.2.5</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
<configuration>
<includes>
<include>**/*IT.java</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
Why this matters
- All integration tests must end with *IT.java.
- Surefire runs only unit tests.
- Failsafe runs ITs only on mvn verify.
Conclusion
Mocking databases may speed up tests, but it often hides the very problems that matter most in production—constraints, transactions, schema correctness, and real SQL behavior. In modern backend systems, this gap between mocked tests and reality is a major source of late-stage bugs and deployment risk.
Testcontainers solves this by enabling developers to test against real databases in fully isolated, reproducible environments—without adding operational complexity. It brings integration testing much closer to production while remaining developer- and CI-friendly.
Move to Part 2, we’ll explore practice by writing real DAO integration tests using Spring Boot, JUnit 5, and PostgreSQL with Testcontainers, along with best practices and CI/CD setup to make this approach fast, reliable, and scalable.
At Zen8Labs, we help engineering teams design production-grade testing strategies, modern CI/CD pipelines, and backend architectures that scale with confidence. If your team is looking to improve test reliability or adopt tools like Testcontainers effectively, you can learn more about how we work at zen8labs.com.
Luan Dinh, Software Engineer