
Writing a Real DAL Integration Test with Testcontainers
Let’s say you have a simple UserRepository with CRUD logic.
We’ll write a test that:
- Starts PostgreSQL in a Docker container
- Applies database schema
- Runs real repository operations
- Verifies real database behavior
1. Create the PostgreSQLContainer
@Testcontainers
public class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
Why static?
Because it starts once for the entire test class, making tests much faster.
2. Register Database Properties in Spring Boot
If using Spring Boot JPA:
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void overrideProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
Now your repository connects to the Testcontainers PostgreSQL automatically.
3. Writing the Actual Test
Example CRUD test:
@Autowired
UserRepository userRepository;
@Test
void testCreateUser() {
User user = new User(null, "alice", "alice@example.com");
User saved = userRepository.save(user);
assertNotNull(saved.getId());
assertEquals("alice", saved.getUsername());
}
Foreign key test:
@Test
void testForeignKeyViolation() {
Order order = new Order(null, 9999L, "ITEM-001"); // 9999 = non-existent user ID
assertThrows(DataIntegrityViolationException.class, () -> {
orderRepository.save(order);
});
}
This test would NEVER fail in a mocked setup.
Best Practices for Using Testcontainers
1. Use Alpine-based images
They start significantly faster.
postgres:16-alpine
2. Use static containers per test class
Reduces startup cost from ~2 seconds → ~200ms per test.
3. Use reusable containers locally (optional)
Create file:
~/.testcontainers.properties
Add:
testcontainers.reuse.enable=true
⚠ Not recommended for CI.
4. Keep migrations small and precise
Large SQL migrations slow down every integration test.
Break the schema into modules, and use schema migration tools like Liquibase or Flyway to make sure the test database has the same structure as the real one.
5. Separate unit tests from integration tests
Use different folders:
src/test/java → unit tests
src/integrationTest/java → Testcontainers tests
Or use tags:
@Tag("integration")
Running Testcontainers on CI/CD

Yes — As long as the CI agent environment can run Docker, it would be easy peasy.
Here is a simple workflow using GitHub Action.
But before rolling into the action, remember that you should not run integration tests all the time, since the test itself is expensive in resource. Here is what you can consider doing:
| Event | What runs | Purpose |
| Pull Request (merge request) | Unit tests only | Fast, cheap, catches logic errors before merging |
| After merge → on main branch | Integration tests (Testcontainers) + Build artifact | Only run expensive tests when code is about to be released |
1. GitHub Actions Workflow File
In .github/workflows/pr-tests.yml
name: Pull Request - Unit Tests
on:
pull_request:
branches: [ main ]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Run Unit Tests Only
run: mvn -B -DskipITs=true test
HOW it works
- You mark integration tests with Maven failsafe (e.g., *IT.java).
- Unit tests run only with mvn test (failsafe won’t run unless you run the integration test phase).
- The PR stays fast and does not pull Docker/Testcontainers.
2. Main branch workflow – integration tests + build artifact
File: .github/workflows/main-release.yml
name: Main Branch - Integration Tests and Build
on:
push:
branches: [ main ]
jobs:
integration-tests-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Run Integration Tests (Testcontainers)
run: mvn -B verify
- name: Package Application JAR
run: mvn -B -DskipTests package
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: app-jar
path: target/*.jar
HOW it works
- Running mvn verify triggers:
- Unit tests (mvn test)
- Integration tests (failsafe:integration-test + failsafe:verify)
- Testcontainers automatically spins up Docker DB/container environments.
- After tests pass, Maven builds your production JAR.
- GitHub uploads the artifact.
Conclusion
Mocking the database layer is a thing of the past. For real reliability, you need real database behavior, and Testcontainers makes that incredibly easy — whether locally or in CI pipelines.
With:
- JUnit integration
- Real database isolation
- Wide range of framework support, Spring Boot included
- Can easily be integrated to any CI tools with Docker
…Testcontainers gives Java developers a powerful, modern testing workflow that dramatically improves code quality.
If you found this post useful, consider checking out the Testcontainers Developer Guide or exploring sample apps on GitHub to deepen your understanding.
For backend applications that rely heavily on database operations, Testcontainers is one of the highest-ROI tools you can add to your engineering stack. If your team is looking to improve development workflows, automate testing pipelines, or adopt modern tooling like Testcontainers at scale, zen8labs provides engineering-focused consulting and technical solutions to help you get there faster.
Luan Dinh, Software Engineer