Testcontainers is a Java library that provides lightweight, throwaway instances of databases and other dependencies for integration testing. It enables you to write real integration tests against actual databases without mocking or using in-memory databases.
Why Testcontainers for Database Testing?
Advantages:
- Real Databases: Test against actual database engines (PostgreSQL, MySQL, etc.)
- Isolation: Each test gets a clean database instance
- Consistency: Same environment in development, CI, and production
- No Mocks: Real SQL, real constraints, real behavior
- Easy Setup: Simple API for database lifecycle management
- Docker Based: Uses Docker containers for dependency isolation
Setup and Dependencies
Maven Dependencies
<properties>
<testcontainers.version>1.19.3</testcontainers.version>
</properties>
<dependencies>
<!-- Testcontainers Core -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5 Integration -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- Database Modules -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Test Database Drivers -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Gradle Setup
dependencies {
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
testImplementation 'org.testcontainers:postgresql:1.19.3'
testImplementation 'org.testcontainers:mysql:1.19.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
Basic Testcontainers Examples
Example 1: Simple PostgreSQL Container Test
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class BasicPostgresTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
@Test
void testDatabaseConnection() throws Exception {
// Get connection details from container
String jdbcUrl = postgres.getJdbcUrl();
String username = postgres.getUsername();
String password = postgres.getPassword();
try (Connection connection = DriverManager.getConnection(jdbcUrl, username, password);
Statement statement = connection.createStatement()) {
// Create table
statement.execute("CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100), email VARCHAR(100))");
// Insert data
statement.execute("INSERT INTO users (name, email) VALUES ('John Doe', '[email protected]')");
// Query data
ResultSet resultSet = statement.executeQuery("SELECT * FROM users");
// Verify results
assertTrue(resultSet.next());
assertEquals("John Doe", resultSet.getString("name"));
assertEquals("[email protected]", resultSet.getString("email"));
}
}
@Test
void testIsolatedDatabase() throws Exception {
// This test runs in the same container but gets a clean database
String jdbcUrl = postgres.getJdbcUrl();
String username = postgres.getUsername();
String password = postgres.getPassword();
try (Connection connection = DriverManager.getConnection(jdbcUrl, username, password);
Statement statement = connection.createStatement()) {
// This table doesn't exist from the previous test
ResultSet resultSet = statement.executeQuery(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'users'");
resultSet.next();
assertEquals(0, resultSet.getInt(1)); // Table should not exist
}
}
}
Example 2: MySQL Container with Custom Configuration
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class MySQLTest {
@Container
private static final MySQLContainer<?> mysql =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass")
.withCommand("--default-authentication-plugin=mysql_native_password")
.withUrlParam("serverTimezone", "UTC")
.withUrlParam("useSSL", "false");
@Test
void testMySQLFunctionality() throws Exception {
String jdbcUrl = mysql.getJdbcUrl();
String username = mysql.getUsername();
String password = mysql.getPassword();
try (Connection connection = DriverManager.getConnection(jdbcUrl, username, password);
Statement statement = connection.createStatement()) {
// Test MySQL specific features
statement.execute("CREATE TABLE products (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"name VARCHAR(255) NOT NULL, " +
"price DECIMAL(10,2), " +
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
statement.execute("INSERT INTO products (name, price) VALUES ('Laptop', 999.99)");
statement.execute("INSERT INTO products (name, price) VALUES ('Mouse', 29.99)");
var resultSet = statement.executeQuery("SELECT COUNT(*) FROM products");
resultSet.next();
assertEquals(2, resultSet.getInt(1));
// Test MySQL functions
resultSet = statement.executeQuery("SELECT NOW() as current_time");
resultSet.next();
assertNotNull(resultSet.getTimestamp("current_time"));
}
}
}
Spring Boot Integration
Example 3: Spring Boot with Testcontainers
Application Configuration:
// src/main/java/com/example/config/DatabaseConfig.java
@Configuration
public class DatabaseConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}
}
// src/main/java/com/example/model/User.java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(unique = true, nullable = false)
private String email;
private LocalDateTime createdAt;
// Constructors, getters, setters
public User() {}
public User(String name, String email) {
this.name = name;
this.email = email;
this.createdAt = LocalDateTime.now();
}
// Getters and setters...
}
// src/main/java/com/example/repository/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByNameContainingIgnoreCase(String name);
}
Test Configuration:
// src/test/java/com/example/config/TestContainersConfig.java
@TestConfiguration
public class TestContainersConfig {
@Bean
@ServiceConnection // Spring Boot 3.1+
public PostgreSQLContainer<?> postgreSQLContainer() {
return new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
}
// For Spring Boot < 3.1, use this approach:
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
postgres.start();
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
Integration Tests:
// src/test/java/com/example/repository/UserRepositoryTest.java
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine");
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldSaveUser() {
User user = new User("John Doe", "[email protected]");
User saved = userRepository.save(user);
assertNotNull(saved.getId());
assertEquals("John Doe", saved.getName());
assertEquals("[email protected]", saved.getEmail());
// Verify in database
User found = entityManager.find(User.class, saved.getId());
assertEquals(saved.getName(), found.getName());
}
@Test
void shouldFindUserByEmail() {
User user = new User("Jane Smith", "[email protected]");
userRepository.save(user);
Optional<User> found = userRepository.findByEmail("[email protected]");
assertTrue(found.isPresent());
assertEquals("Jane Smith", found.get().getName());
}
@Test
void shouldReturnEmptyWhenUserNotFound() {
Optional<User> found = userRepository.findByEmail("[email protected]");
assertTrue(found.isEmpty());
}
@Test
void shouldFindUsersByNameContaining() {
userRepository.save(new User("John Doe", "[email protected]"));
userRepository.save(new User("John Smith", "[email protected]"));
userRepository.save(new User("Jane Doe", "[email protected]"));
List<User> johns = userRepository.findByNameContainingIgnoreCase("john");
assertEquals(2, johns.size());
assertTrue(johns.stream().allMatch(user ->
user.getName().toLowerCase().contains("john")));
}
}
Advanced Testcontainers Features
Example 4: Database Migration with Flyway/Liquibase
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class DatabaseMigrationTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("migration_test")
.withUsername("test")
.withPassword("test");
private Flyway flyway;
@BeforeEach
void setUp() {
// Configure Flyway
flyway = Flyway.configure()
.dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
.locations("classpath:db/migration")
.load();
// Clean and migrate
flyway.clean();
flyway.migrate();
}
@Test
void shouldApplyMigrations() throws Exception {
try (Connection connection = postgres.createConnection("");
var statement = connection.createStatement()) {
// Verify tables were created
ResultSet tables = statement.executeQuery(
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = 'public' ORDER BY table_name");
List<String> tableNames = new ArrayList<>();
while (tables.next()) {
tableNames.add(tables.getString("table_name"));
}
assertTrue(tableNames.contains("users"));
assertTrue(tableNames.contains("products"));
assertTrue(tableNames.contains("orders"));
// Verify data can be inserted
statement.execute("INSERT INTO users (name, email) VALUES ('Test User', '[email protected]')");
ResultSet users = statement.executeQuery("SELECT COUNT(*) FROM users");
users.next();
assertEquals(1, users.getInt(1));
}
}
}
Example 5: Shared Container for Multiple Test Classes
// src/test/java/com/example/containers/TestDatabaseContainer.java
public class TestDatabaseContainer {
private static final PostgreSQLContainer<?> CONTAINER;
static {
CONTAINER = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // Allows container reuse between test runs
CONTAINER.start();
// Register shutdown hook
Runtime.getRuntime().addShutdownHook(new Thread(CONTAINER::stop));
}
public static PostgreSQLContainer<?> getInstance() {
return CONTAINER;
}
public static String getJdbcUrl() {
return CONTAINER.getJdbcUrl();
}
public static String getUsername() {
return CONTAINER.getUsername();
}
public static String getPassword() {
return CONTAINER.getPassword();
}
}
// src/test/java/com/example/repository/ProductRepositoryTest.java
@SpringBootTest
@Testcontainers
class ProductRepositoryTest {
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", TestDatabaseContainer::getJdbcUrl);
registry.add("spring.datasource.username", TestDatabaseContainer::getUsername);
registry.add("spring.datasource.password", TestDatabaseContainer::getPassword);
}
@Autowired
private ProductRepository productRepository;
@Test
void shouldSaveProduct() {
Product product = new Product("Laptop", "Gaming laptop", 999.99);
Product saved = productRepository.save(product);
assertNotNull(saved.getId());
assertEquals("Laptop", saved.getName());
}
}
// src/test/java/com/example/service/OrderServiceTest.java
@SpringBootTest
@Testcontainers
class OrderServiceTest {
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", TestDatabaseContainer::getJdbcUrl);
registry.add("spring.datasource.username", TestDatabaseContainer::getUsername);
registry.add("spring.datasource.password", TestDatabaseContainer::getPassword);
}
@Autowired
private OrderService orderService;
@Autowired
private UserRepository userRepository;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCreateOrder() {
User user = userRepository.save(new User("John Doe", "[email protected]"));
Product product = productRepository.save(new Product("Mouse", "Wireless mouse", 29.99));
Order order = orderService.createOrder(user.getId(),
List.of(new OrderItemRequest(product.getId(), 2)));
assertNotNull(order.getId());
assertEquals(59.98, order.getTotalAmount().doubleValue(), 0.01);
}
}
Example 6: Testing Stored Procedures and Database Functions
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Types;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class StoredProcedureTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("procedure_test")
.withUsername("test")
.withPassword("test");
@BeforeAll
static void setUp() throws Exception {
try (Connection connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
var statement = connection.createStatement()) {
// Create test tables
statement.execute("""
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
salary DECIMAL(10,2) NOT NULL,
department VARCHAR(50)
)
""");
// Create stored procedure
statement.execute("""
CREATE OR REPLACE FUNCTION calculate_bonus(
employee_id INTEGER,
bonus_percentage DECIMAL
) RETURNS DECIMAL AS $$
DECLARE
emp_salary DECIMAL;
bonus_amount DECIMAL;
BEGIN
SELECT salary INTO emp_salary
FROM employees WHERE id = employee_id;
IF emp_salary IS NULL THEN
RETURN 0;
END IF;
bonus_amount := emp_salary * (bonus_percentage / 100);
RETURN bonus_amount;
END;
$$ LANGUAGE plpgsql;
""");
// Insert test data
statement.execute("""
INSERT INTO employees (name, salary, department) VALUES
('John Doe', 50000.00, 'Engineering'),
('Jane Smith', 60000.00, 'Marketing'),
('Bob Johnson', 75000.00, 'Engineering')
""");
}
}
@Test
void shouldCalculateBonus() throws Exception {
try (Connection connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
CallableStatement callableStatement = connection.prepareCall(
"{ ? = call calculate_bonus(?, ?) }")) {
callableStatement.registerOutParameter(1, Types.DECIMAL);
callableStatement.setInt(2, 1); // employee_id
callableStatement.setBigDecimal(3, new java.math.BigDecimal("10.0")); // 10% bonus
callableStatement.execute();
java.math.BigDecimal bonus = callableStatement.getBigDecimal(1);
assertEquals(0, new java.math.BigDecimal("5000.00").compareTo(bonus));
}
}
@Test
void shouldReturnZeroForNonexistentEmployee() throws Exception {
try (Connection connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
CallableStatement callableStatement = connection.prepareCall(
"{ ? = call calculate_bonus(?, ?) }")) {
callableStatement.registerOutParameter(1, Types.DECIMAL);
callableStatement.setInt(2, 999); // nonexistent employee
callableStatement.setBigDecimal(3, new java.math.BigDecimal("10.0"));
callableStatement.execute();
java.math.BigDecimal bonus = callableStatement.getBigDecimal(1);
assertEquals(0, java.math.BigDecimal.ZERO.compareTo(bonus));
}
}
}
Performance Optimization
Example 7: Container Reuse and Parallel Execution
// src/test/resources/testcontainers.properties
testcontainers.reuse.enable=true
// src/test/java/com/example/ParallelTest.java
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
@Execution(ExecutionMode.CONCURRENT)
@Testcontainers
class ParallelDatabaseTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("parallel_test")
.withReuse(true);
@Test
void testOne() throws Exception {
executeTest("test_one");
}
@Test
void testTwo() throws Exception {
executeTest("test_two");
}
@Test
void testThree() throws Exception {
executeTest("test_three");
}
private void executeTest(String testName) throws Exception {
try (Connection connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
var statement = connection.createStatement()) {
statement.execute("CREATE TABLE " + testName + " (id SERIAL PRIMARY KEY, data VARCHAR(100))");
statement.execute("INSERT INTO " + testName + " (data) VALUES ('" + testName + "')");
var resultSet = statement.executeQuery("SELECT data FROM " + testName);
resultSet.next();
assertEquals(testName, resultSet.getString("data"));
}
}
}
Best Practices
- Container Reuse: Enable reuse for faster test execution
- Database Cleanup: Use Flyway clean or truncate tables between tests
- Parallel Execution: Run independent tests in parallel
- Resource Management: Always close connections and resources
- Configuration: Externalize database configuration
- CI Integration: Configure Testcontainers for your CI environment
- Network Policies: Handle network restrictions in corporate environments
Common Issues and Solutions
Docker Daemon Not Running
// Check if Docker is available
class DockerCheck {
public static boolean isDockerAvailable() {
try {
DockerClientFactory.instance().client();
return true;
} catch (Exception e) {
return false;
}
}
}
Resource Cleanup
@AfterEach
void cleanup() {
// Clean up test data
userRepository.deleteAll();
productRepository.deleteAll();
}
@AfterAll
static void stopContainer() {
// Only if not using reusable containers
// postgres.stop();
}
Conclusion
Testcontainers provides a robust solution for database testing in Java:
Key Benefits:
- Real Database Testing: Test against actual database engines
- Isolation: Clean database for each test suite
- Consistency: Same environment everywhere
- Easy Integration: Simple setup with Spring Boot and JUnit 5
- Flexibility: Support for multiple databases and configurations
When to Use Testcontainers:
- Integration testing with real databases
- Testing database migrations
- Testing stored procedures and functions
- Complex query testing
- Testing database-specific features
- CI/CD pipeline testing
Testcontainers eliminates the gap between development and production databases, providing confidence that your database interactions work correctly in real environments.
Next Steps: Explore Testcontainers modules for other services like Redis, Kafka, and Elasticsearch. Implement container reuse strategies for faster test execution and integrate with your CI/CD pipeline for reliable database testing.