Testcontainers with Spring Boot
Testcontainers with Spring Boot lets your tests run against real services, such as PostgreSQL, MongoDB or Kafka, started in throwaway Docker containers. Instead of an in-memory stand-in like H2 that only behaves roughly like your production database, every test talks to the same database engine and version you deploy. Bugs in SQL, migrations and locking show up on your laptop instead of in production.
This tutorial explains how Testcontainers works with Spring Boot, then covers @Testcontainers and @Container, @ServiceConnection vs @DynamicPropertySource, sharing one container across test classes, and running your app locally against containers. It builds a complete order service on Spring Boot 4.1 with PostgreSQL and Flyway, tests it with @DataJpaTest, a concurrency test and full HTTP tests, and shows the real errors behind the most common pitfalls.
1. What Is Testcontainers?
Testcontainers is a Java library that starts Docker containers from your test code and removes them when the tests finish. It has ready-made modules for common services, such as PostgreSQLContainer or MongoDBContainer, which know how to start the service, wait until it is ready and tell you how to connect. For anything else there is GenericContainer, which runs any image.
Spring Boot adds the glue. With @ServiceConnection, Boot reads the container's host, port and credentials and configures the matching connection, such as the DataSource, automatically. You never copy a URL or port into a properties file.
2. Why Test Against a Real Database Instead of H2
H2 is fast and needs no Docker, but it is a different database. It understands much of PostgreSQL's SQL, not all of it. The example's Flyway migration uses PostgreSQL types and features such as TIMESTAMPTZ, a CHECK constraint and a partial index. Running the same @DataJpaTest against H2 fails before a single test runs:
o.f.core.FlywayExecutor : Database: jdbc:h2:mem:412882db-508b-4542-9575-c3000adf456d (H2 2.4)
...
Caused by: org.flywaydb.core.internal.exception.FlywayMigrateException: Failed to execute script V1__create_orders.sql
Caused by: org.h2.jdbc.JdbcSQLNonTransientException: Unknown data type: "TIMESTAMPTZ"; SQL statement:
Tests run: 3, Failures: 0, Errors: 3, Skipped: 0
Even when the SQL happens to run on both, behaviour can differ in locking, case sensitivity, constraint names and date handling. The example's job queue uses FOR UPDATE SKIP LOCKED, a PostgreSQL row-locking feature whose behaviour only a real PostgreSQL can show. Testcontainers removes the guesswork: if the tests pass, the SQL works on the engine you ship.
3. How Testcontainers Works with Spring Boot
- Before the Spring context starts, Testcontainers asks Docker to start the container. Docker maps the container's port (5432) to a random free port on your machine, so parallel builds never clash.
- Testcontainers waits until the service is ready, for PostgreSQL until it accepts connections.
- Spring Boot finds the container marked
@ServiceConnectionand creates a ConnectionDetails bean (hereJdbcConnectionDetails) from it. Auto-configuration uses those details in place ofspring.datasource.*. - Your tests run against the real database. Flyway migrates it like any other.
- When the tests finish, the container is stopped. A small helper container called Ryuk also removes leftovers if the JVM crashes.
4. Setup
You need Docker running locally (Docker Desktop, Rancher Desktop, Colima or a remote Docker host). If containers are new to you, the Docker with Spring Boot tutorial explains the basics. Add the Spring Boot Testcontainers support and the module for your database:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
org.testcontainers:postgresql became testcontainers-postgresql and junit-jupiter became testcontainers-junit-jupiter. The container classes moved too, to org.testcontainers.postgresql.PostgreSQLContainer, which no longer takes a generic type. The old org.testcontainers.containers.PostgreSQLContainer is deprecated. Spring Boot's own @ServiceConnection has been available since Spring Boot 3.1.5. Core Concepts
5.1 @Testcontainers and @Container
@Testcontainers on a test class activates a JUnit 5 extension. It starts every field annotated with @Container and stops it afterwards. Whether the field is static decides how long the container lives:
| Field | Container lifetime | Use with Spring |
|---|---|---|
static | Started once before the first test of the class, stopped after the last | Yes, this is the normal choice |
| Instance (non-static) | A new container for every test method | No. @ServiceConnection rejects it (section 8.2) |
@DataJpaTest
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
}
5.2 @ServiceConnection vs @DynamicPropertySource
There are two ways to point Spring at the container. @ServiceConnection is the modern one. @DynamicPropertySource sets properties yourself and still works for anything Boot doesn't recognise:
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@ServiceConnection | @DynamicPropertySource | |
|---|---|---|
| Code needed | One annotation | One line per property |
| How it works | Creates ConnectionDetails beans that auto-configuration uses | Adds properties with the highest precedence |
| Supported services | Those Spring Boot knows: JDBC databases, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch and more | Anything that is configured through properties |
Works with GenericContainer | Only with name set to a recognised image, e.g. @ServiceConnection(name = "redis") | Yes |
| Use for | Supported services (the default choice) | Custom properties, such as your own client's base URL |
5.3 Sharing a Container with @TestConfiguration
Instead of a field in every test class, declare the container once as a @Bean in a test configuration. Spring then starts the container with the application context and stops it when the context closes. Every test that imports this class and has the same context configuration reuses both the context and the container:
package com.javabytechie.shop;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.postgresql.PostgreSQLContainer;
// One PostgreSQL container for every test that imports this class and shares the same context
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer postgresContainer() {
return new PostgreSQLContainer("postgres:17");
}
}
This is also the layout that Spring Initializr generates when you choose Testcontainers.
5.4 Running the App Locally Against Containers
The same configuration can start your application with a database container, so you don't need a local PostgreSQL install. Put a second main class in src/test/java:
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
// Starts the real application against a PostgreSQL container: ./mvnw spring-boot:test-run
public class TestShopApplication {
public static void main(String[] args) {
SpringApplication.from(ShopApplication::main)
.with(TestcontainersConfiguration.class)
.run(args);
}
}
Run it with ./mvnw spring-boot:test-run (Gradle: ./gradlew bootTestRun). The log shows the container start, the migration and the random port:
INFO tc.postgres:17 : Creating container for image: postgres:17
INFO tc.postgres:17 : Container postgres:17 started in PT0.59911S
INFO o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.004s)
Database JDBC URL [jdbc:postgresql://localhost:61867/test?loggerLevel=OFF]
INFO o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
INFO com.javabytechie.shop.ShopApplication : Started ShopApplication in 2.466 seconds
6. A Complete Example: Testing an Order Service with PostgreSQL
The order service stores orders in PostgreSQL. Flyway creates the schema. Workers claim pending orders in batches with FOR UPDATE SKIP LOCKED, which locks the rows it returns and skips rows another transaction has already locked, so two workers never get the same order. Errors follow one pattern: 404 for an unknown order number and 409 for a duplicate. The Spring Data JPA with Hibernate tutorial and the Flyway migration tutorial cover those parts in depth.
6.1 Schema and Entity
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_number VARCHAR(20) NOT NULL UNIQUE,
customer_email VARCHAR(255) NOT NULL,
total NUMERIC(10, 2) NOT NULL CHECK (total >= 0),
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Workers only look for pending orders, so index just those rows (a partial index)
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'PENDING';
package com.javabytechie.shop.order;
import java.math.BigDecimal;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class CustomerOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String orderNumber;
@Column(nullable = false)
private String customerEmail;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal total;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status;
@Column(nullable = false)
private Instant createdAt;
protected CustomerOrder() {
}
public CustomerOrder(String orderNumber, String customerEmail, BigDecimal total) {
this.orderNumber = orderNumber;
this.customerEmail = customerEmail;
this.total = total;
this.status = OrderStatus.PENDING;
this.createdAt = Instant.now();
}
public void startProcessing() {
this.status = OrderStatus.PROCESSING;
}
public Long getId() { return id; }
public String getOrderNumber() { return orderNumber; }
public String getCustomerEmail() { return customerEmail; }
public BigDecimal getTotal() { return total; }
public OrderStatus getStatus() { return status; }
public Instant getCreatedAt() { return createdAt; }
}
package com.javabytechie.shop.order;
public enum OrderStatus {
PENDING, PROCESSING, SHIPPED
}
spring.application.name=shop
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=shop
# Flyway creates the schema; Hibernate only checks that the entities match it
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
6.2 Repository, Service and Controller
package com.javabytechie.shop.order;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface OrderRepository extends JpaRepository<CustomerOrder, Long> {
Optional<CustomerOrder> findByOrderNumber(String orderNumber);
long countByStatus(OrderStatus status);
// PostgreSQL row locking: rows another transaction has locked are skipped, not waited for
@Query(value = """
SELECT * FROM orders
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT :limit
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<CustomerOrder> lockNextPending(@Param("limit") int limit);
}
package com.javabytechie.shop.order;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public OrderResponse place(PlaceOrderRequest request) {
try {
CustomerOrder order = orderRepository.saveAndFlush(
new CustomerOrder(request.orderNumber(), request.customerEmail(), request.total()));
log.info("Placed order {} for {}", order.getOrderNumber(), order.getCustomerEmail());
return OrderResponse.from(order);
} catch (DataIntegrityViolationException e) {
// The UNIQUE constraint on order_number rejected the insert
throw new DuplicateOrderException(request.orderNumber());
}
}
@Transactional(readOnly = true)
public OrderResponse findByOrderNumber(String orderNumber) {
return orderRepository.findByOrderNumber(orderNumber)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(orderNumber));
}
// Each worker gets a different batch: locked rows stay locked until this transaction commits
@Transactional
public List<OrderResponse> claimPending(int limit) {
List<CustomerOrder> orders = orderRepository.lockNextPending(limit);
orders.forEach(CustomerOrder::startProcessing);
log.info("Claimed {} pending orders: {}", orders.size(),
orders.stream().map(CustomerOrder::getOrderNumber).toList());
return orders.stream().map(OrderResponse::from).toList();
}
}
package com.javabytechie.shop.order;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> place(@RequestBody PlaceOrderRequest request) {
OrderResponse order = orderService.place(request);
return ResponseEntity.created(URI.create("/api/orders/" + order.orderNumber())).body(order);
}
@GetMapping("/{orderNumber}")
public OrderResponse find(@PathVariable String orderNumber) {
return orderService.findByOrderNumber(orderNumber);
}
@PostMapping("/claims")
public List<OrderResponse> claim(@RequestParam(defaultValue = "10") int limit) {
return orderService.claimPending(limit);
}
}
package com.javabytechie.shop.order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class OrderExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail notFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(DuplicateOrderException.class)
public ProblemDetail duplicate(DuplicateOrderException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
public record PlaceOrderRequest(String orderNumber, String customerEmail, BigDecimal total) {
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
public record OrderResponse(String orderNumber, String customerEmail, BigDecimal total, OrderStatus status) {
static OrderResponse from(CustomerOrder order) {
return new OrderResponse(order.getOrderNumber(), order.getCustomerEmail(), order.getTotal(), order.getStatus());
}
}
package com.javabytechie.shop.order;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(String orderNumber) {
super("Order not found: " + orderNumber);
}
}
package com.javabytechie.shop.order;
public class DuplicateOrderException extends RuntimeException {
public DuplicateOrderException(String orderNumber) {
super("Order " + orderNumber + " already exists");
}
}
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
6.3 Repository Test with @DataJpaTest
This test uses the JUnit style from section 5.1. It checks that the migration ran, that the native locking query returns only pending orders, oldest first, and that PostgreSQL's CHECK constraint rejects a negative total. @DataJpaTest rolls back each test's transaction, so tests don't see each other's data:
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.dao.DataIntegrityViolationException;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
@DataJpaTest
@Testcontainers
class OrderRepositoryTest {
// static: one container for all tests in this class, started before the Spring context
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
@Autowired
OrderRepository repository;
@Test
void flywayMigrationRanAgainstPostgres() {
assertThat(repository.count()).isZero();
assertThat(postgres.isRunning()).isTrue();
}
@Test
void lockNextPendingReturnsOldestPendingOrdersFirst() {
repository.save(new CustomerOrder("ORD-1001", "asha@example.com", new BigDecimal("120.00")));
CustomerOrder processing = new CustomerOrder("ORD-1002", "ravi@example.com", new BigDecimal("45.50"));
processing.startProcessing();
repository.save(processing);
repository.save(new CustomerOrder("ORD-1003", "meera@example.com", new BigDecimal("89.99")));
repository.flush();
assertThat(repository.lockNextPending(5))
.extracting(CustomerOrder::getOrderNumber)
.containsExactly("ORD-1001", "ORD-1003");
}
@Test
void checkConstraintRejectsNegativeTotal() {
assertThat(repository.count()).isZero();
assertThatThrownBy(() -> repository.saveAndFlush(
new CustomerOrder("ORD-2001", "asha@example.com", new BigDecimal("-5.00"))))
.isInstanceOf(DataIntegrityViolationException.class)
.hasMessageContaining("orders_total_check");
}
}
@AutoConfigureTestDatabase(replace = NONE) is needed. Its default, Replace.NON_TEST, only swaps in an embedded database when the DataSource did not come from the test itself, and a @ServiceConnection container counts as coming from the test.6.4 Concurrency Test: Two Workers, No Duplicates
Five orders are pending. Two workers start at the same moment and each claims up to three. With SKIP LOCKED, they must get different orders, and together all five:
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CyclicBarrier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import com.javabytechie.shop.TestcontainersConfiguration;
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class OrderClaimIntegrationTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository repository;
@BeforeEach
void setUp() {
repository.deleteAll();
for (int i = 1; i <= 5; i++) {
orderService.place(new PlaceOrderRequest("ORD-300" + i, "buyer" + i + "@example.com", new BigDecimal("10.00")));
}
}
@Test
void twoWorkersClaimDifferentOrders() {
assertThat(repository.countByStatus(OrderStatus.PENDING)).isEqualTo(5);
// Start both workers at the same moment
CyclicBarrier start = new CyclicBarrier(2);
CompletableFuture<List<OrderResponse>> workerA = CompletableFuture.supplyAsync(() -> claimAfter(start));
CompletableFuture<List<OrderResponse>> workerB = CompletableFuture.supplyAsync(() -> claimAfter(start));
List<String> a = workerA.join().stream().map(OrderResponse::orderNumber).toList();
List<String> b = workerB.join().stream().map(OrderResponse::orderNumber).toList();
Set<String> all = new HashSet<>(a);
all.addAll(b);
assertThat(a).doesNotContainAnyElementsOf(b);
assertThat(all).hasSize(5);
assertThat(repository.countByStatus(OrderStatus.PENDING)).isZero();
assertThat(repository.countByStatus(OrderStatus.PROCESSING)).isEqualTo(5);
}
private List<OrderResponse> claimAfter(CyclicBarrier start) {
try {
start.await();
} catch (Exception e) {
throw new IllegalStateException(e);
}
return orderService.claimPending(3);
}
}
INFO c.javabytechie.shop.order.OrderService : Claimed 3 pending orders: [ORD-3001, ORD-3002, ORD-3003]
INFO c.javabytechie.shop.order.OrderService : Claimed 2 pending orders: [ORD-3004, ORD-3005]
@SpringBootTest does not roll back, because the service's own transactions commit, so the test cleans the table in @BeforeEach.
6.5 HTTP Test with RestTestClient
The last test starts the real web server on a random port and calls the API with RestTestClient, the HTTP test client added in Spring Framework 7. It is enabled by @AutoConfigureRestTestClient:
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.client.RestTestClient;
import com.javabytechie.shop.TestcontainersConfiguration;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Import(TestcontainersConfiguration.class)
class OrderApiIntegrationTest {
@Autowired
RestTestClient client;
@Autowired
OrderRepository repository;
@BeforeEach
void cleanDatabase() {
repository.deleteAll();
}
@Test
void placeAndFetchOrder() {
PlaceOrderRequest request = new PlaceOrderRequest("ORD-4001", "asha@example.com", new BigDecimal("249.00"));
assertThat(repository.findByOrderNumber("ORD-4001")).isEmpty();
client.post().uri("/api/orders").body(request).exchange()
.expectStatus().isCreated()
.expectHeader().location("/api/orders/ORD-4001");
client.get().uri("/api/orders/ORD-4001").exchange()
.expectStatus().isOk()
.expectBody(OrderResponse.class)
.isEqualTo(new OrderResponse("ORD-4001", "asha@example.com", new BigDecimal("249.00"), OrderStatus.PENDING));
assertThat(repository.count()).isEqualTo(1);
}
@Test
void duplicateOrderNumberReturns409() {
PlaceOrderRequest request = new PlaceOrderRequest("ORD-4002", "ravi@example.com", new BigDecimal("15.00"));
client.post().uri("/api/orders").body(request).exchange().expectStatus().isCreated();
client.post().uri("/api/orders").body(request).exchange()
.expectStatus().isEqualTo(HttpStatus.CONFLICT)
.expectBody().jsonPath("$.detail").isEqualTo("Order ORD-4002 already exists");
assertThat(repository.count()).isEqualTo(1);
}
@Test
void unknownOrderReturns404() {
client.get().uri("/api/orders/ORD-9999").exchange()
.expectStatus().isNotFound()
.expectBody().jsonPath("$.detail").isEqualTo("Order not found: ORD-9999");
}
}
6.6 Run the Tests
Run ./mvnw test. Each container starts in under a second once its image has been downloaded:
INFO tc.postgres:17 : Container postgres:17 started in PT0.830193S
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderRepositoryTest
INFO tc.postgres:17 : Container postgres:17 started in PT0.638904S
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderClaimIntegrationTest
INFO tc.postgres:17 : Container postgres:17 started in PT0.661588S
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderApiIntegrationTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
The log shows three containers for three test classes. The next section explains why, and when that can be reduced.
7. Sharing Containers to Speed Up Tests
Spring's test framework caches application contexts and reuses one whenever a later test class has exactly the same configuration. A container declared as a bean lives as long as its context, so sharing the context means sharing the container:
OrderRepositoryTesthas its own static@Containerfield, so it always gets its own container.OrderClaimIntegrationTest(mock web environment) andOrderApiIntegrationTest(RANDOM_PORTplus@AutoConfigureRestTestClient) both importTestcontainersConfiguration, but their configurations differ. That gives two contexts, and so two containers.
To share more, give integration tests the same annotations, for example through a common base class or a custom annotation, and avoid per-class differences such as extra @MockitoBean fields or property overrides. Also make sure the image is already downloaded on CI agents, and pin the image tag so the cache stays valid.
8. Common Pitfalls and How to Fix Them
8.1 "Could not find a valid Docker environment"
Docker is not running, or Testcontainers cannot reach it. The tests fail before Spring starts:
ERROR o.t.dockerclient.DockerClientProviderStrategy -- Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
UnixSocketClientProviderStrategy: failed with exception InternalServerErrorException (Status 500: )
DockerDesktopClientProviderStrategy: failed with exception InternalServerErrorException (Status 500: )As no valid configuration was found, execution cannot continue.
...
Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration
How to fix it: start Docker and check that docker ps works. For a remote or non-default Docker setup, set DOCKER_HOST. Testcontainers does not use the Docker CLI's current context, so a context that points elsewhere does not matter.
8.2 "@ServiceConnection field must be static"
Removing static from the container field fails immediately:
java.lang.IllegalStateException: @ServiceConnection field 'postgres' must be static
How to fix it: make the field static. Spring needs the connection details before it builds the context, and the context is shared by all test methods.
8.3 Hard-Coded Hosts and Ports
The container's port 5432 is published on a random host port, 61867 in the run in section 5.4, so a URL such as jdbc:postgresql://localhost:5432/test in a test properties file points at the wrong place, or at a local PostgreSQL you didn't mean to use. How to fix it: let @ServiceConnection supply the connection, or read getJdbcUrl() and getMappedPort(5432) from the container.
8.4 Data Leaking Between Tests
A shared container keeps its data for the whole run. @DataJpaTest rolls back for you, but @SpringBootTest does not, and an HTTP test cannot roll back the server's transactions at all. How to fix it: clean up in @BeforeEach, as the integration tests do, or give each test its own unique data, such as distinct order numbers.
8.5 Floating Image Tags
new PostgreSQLContainer("postgres:latest") silently moves to a new major version one day, and tests start failing (or passing) for reasons unrelated to your change. How to fix it: pin the same major version you run in production, such as postgres:17.
9. When to Use Testcontainers
| Good fit | Poor fit |
|---|---|
| Repository tests with native queries, migrations or database-specific features | Pure business logic, which a plain unit test with mocks checks in milliseconds |
| Integration tests across the web layer, service and database | Controller-only tests, where @WebMvcTest with a mocked service is enough |
| Testing against MongoDB, Redis, Kafka or RabbitMQ | Build environments where Docker is not available and cannot be added |
| Concurrency and locking behaviour | Very large test suites that would start dozens of distinct contexts (share them first) |
A healthy suite combines both: many fast unit and slice tests, plus a focused set of Testcontainers tests for the parts that touch real infrastructure. The Spring Boot testing tutorial covers the other test types.
10. Best Practices
- Use the same database engine and major version as production, and pin the image tag.
- Prefer @ServiceConnection over
@DynamicPropertySourcefor services Spring Boot supports. - Declare shared containers once in a
@TestConfigurationand import it, instead of copying container fields into every class. - Keep test configurations identical where you can, so Spring reuses contexts and containers.
- Make container fields static when you use
@Container. - Let Flyway or Liquibase build the schema in tests, so migrations are tested on every run.
- Clean up data in @SpringBootTest tests, which do not roll back.
- Assert state before and after the action, not just after.
- Reuse the configuration for local development with
spring-boot:test-run. - Keep fast tests fast: use Testcontainers where real infrastructure matters, not for every test.
11. Summary
Testcontainers with Spring Boot replaces in-memory stand-ins with the real thing: a PostgreSQL, MongoDB or Kafka container that starts with your tests and disappears afterwards. Add spring-boot-testcontainers and the module for your service. Declare the container as a static @Container field or, better, as a @Bean in a shared @TestConfiguration, and mark it @ServiceConnection so Spring Boot connects to it automatically. Use it in @DataJpaTest slices and full @SpringBootTest tests, and even to run the app locally with spring-boot:test-run. The main recommendation: test database code against the same engine and version you run in production, and share containers through identical test contexts to keep the suite fast.
Frequently Asked Questions (FAQ)
Q1. What is the difference between @ServiceConnection and @DynamicPropertySource?
@ServiceConnection lets Spring Boot read the container's address and credentials and create ConnectionDetails beans automatically, with no property names in your test. @DynamicPropertySource registers properties yourself from the container's getters. Use @ServiceConnection for services Spring Boot supports and @DynamicPropertySource for custom properties.
Q2. Do I need @AutoConfigureTestDatabase(replace = NONE) with Testcontainers?
Not with current Spring Boot versions. The default, Replace.NON_TEST, keeps a DataSource that comes from the test, such as a @ServiceConnection container, and only replaces one defined in your application configuration. Older tutorials add replace = NONE because older Spring Boot versions replaced any DataSource with an embedded database.
Q3. Why does Testcontainers say "Could not find a valid Docker environment"?
Testcontainers cannot reach a Docker daemon. Start Docker Desktop (or your Docker engine) and check that docker ps works. If Docker runs somewhere unusual, set DOCKER_HOST, because Testcontainers ignores the Docker CLI's current context.
Q4. How do I reuse one container across all test classes?
Declare the container as a @Bean with @ServiceConnection in a @TestConfiguration class and import it into your tests. Spring caches the context, and with it the container, for every test class with the same configuration. Differences such as extra @MockitoBean fields or a different web environment create a new context and a new container.
Q5. Is Testcontainers slow?
Starting a PostgreSQL container takes under a second once the image is cached; the first run pays for the image download. Most of the cost comes from starting many distinct Spring contexts, each with its own container. Share contexts, keep fast logic in plain unit tests, and pre-pull images on CI agents.
Q6. Can I use Testcontainers on a CI server?
Yes, as long as the build agent can run Docker containers. Hosted runners such as GitHub Actions' Linux runners have Docker installed. On Kubernetes-based agents you need a Docker-compatible engine available to the build, or a remote Docker host.
Q7. Should I replace H2 with Testcontainers?
For anything that touches SQL, migrations or database-specific features, yes. H2 accepts a different SQL dialect and behaves differently in locking and constraints, so tests can pass on H2 and fail in production. Plain unit tests that don't use a database need neither.
Q8. How do I run my Spring Boot app locally with Testcontainers?
Create a main class in src/test/java that calls SpringApplication.from(ShopApplication::main).with(TestcontainersConfiguration.class).run(args), then start it with ./mvnw spring-boot:test-run or ./gradlew bootTestRun. The app starts with the same containers your tests use, so no local database install is needed.