Integration Testing with @SpringBootTest and Testcontainers
Integration tests verify that all layers work together — HTTP → controller → service → repository → database. This article shows how to write them efficiently with Testcontainers and manage test isolation.
@SpringBootTest — The Full Context
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderIntegrationTest {
// Loads the FULL Spring ApplicationContext
// Everything: controllers, services, repositories, security
// Starts on a random port (avoids port conflicts when running tests in parallel)
}
WebEnvironment options
| Option | What it starts | Use for |
|---|---|---|
RANDOM_PORT | Embedded server on random port | Full HTTP round-trip tests |
DEFINED_PORT | Embedded server on configured port | When you need a fixed port |
MOCK (default) | No real server, MockMvc available | Fast tests without real HTTP |
NONE | No server at all | Service/repo tests only |
Testcontainers — Real Database
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
Using @ServiceConnection (Spring Boot 3.1+)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection // auto-configures spring.datasource.* from the container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldCreateAndRetrieveOrder() {
// Create
var request = new CreateOrderRequest(
UUID.randomUUID(),
List.of(new OrderItemRequest(UUID.randomUUID(), 2))
);
ResponseEntity<OrderResponse> createResponse = restTemplate.postForEntity(
"/api/orders",
request,
OrderResponse.class
);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
UUID orderId = createResponse.getBody().id();
// Retrieve
ResponseEntity<OrderResponse> getResponse = restTemplate.getForEntity(
"/api/orders/{id}",
OrderResponse.class,
orderId
);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().status()).isEqualTo(OrderStatus.PENDING);
}
}
@ServiceConnection reads the container’s JDBC URL, username, and password and sets them in the Spring context automatically — no @DynamicPropertySource needed.
Using @DynamicPropertySource (older approach)
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
Test Isolation Strategies
The hardest part of integration testing: ensuring tests don’t affect each other.
Strategy 1: @Transactional on tests (rollback)
@SpringBootTest
@Testcontainers
@Transactional // each test method runs in a transaction, rolls back at the end
class OrderServiceIntegrationTest {
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
void shouldCreateOrder() {
Order order = orderService.create(validRequest());
assertThat(orderRepository.findById(order.getId())).isPresent();
// Transaction rolls back — order is deleted after this test
}
}
Limitation: @Transactional on the test and RANDOM_PORT don’t mix well — the server runs in a different thread and a different transaction.
Strategy 2: Clean up in @AfterEach
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderControllerIntegrationTest {
@Autowired OrderRepository orderRepository;
@Autowired CustomerRepository customerRepository;
@AfterEach
void cleanup() {
orderRepository.deleteAll();
customerRepository.deleteAll();
}
@Test
void shouldCreateOrder() { ... }
}
Explicit cleanup — more verbose but works with any WebEnvironment.
Strategy 3: Truncate tables between tests (fastest)
@SpringBootTest
@Testcontainers
class IntegrationTestBase {
@Autowired JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDatabase() {
jdbcTemplate.execute("TRUNCATE TABLE orders, order_items, customers RESTART IDENTITY CASCADE");
}
}
Truncate is faster than delete for large tables. CASCADE handles FK constraints.
Strategy 4: Use a fresh database per test class
// Container started fresh for each test class
class OrderIntegrationTest {
@Container
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16"); // not static!
// Started and stopped per test class instance
}
Slowest but most isolated — good for tests that change the schema.
TestRestTemplate — Full HTTP Testing
TestRestTemplate makes HTTP requests through the actual embedded server:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
// TestRestTemplate with auth
private TestRestTemplate authedRestTemplate;
@BeforeEach
void setup() {
// Configure Basic Auth or JWT for authenticated tests
authedRestTemplate = restTemplate.withBasicAuth("admin", "password");
}
@Test
void shouldReturnAllOrders() {
ResponseEntity<List<OrderResponse>> response = authedRestTemplate.exchange(
"/api/orders",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<OrderResponse>>() {}
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
void shouldCreateOrder() {
var request = new CreateOrderRequest(customerId, items);
ResponseEntity<OrderResponse> response = authedRestTemplate.postForEntity(
"/api/orders",
request,
OrderResponse.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getHeaders().getLocation()).isNotNull();
}
@Test
void shouldReturn401ForUnauthenticatedRequest() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/orders", String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
}
@MockBean in Integration Tests
Occasionally you need a real context but want to mock an external dependency:
@SpringBootTest
@Testcontainers
class OrderServiceWithMockedEmailTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
private OrderService orderService;
@MockBean
private EmailService emailService; // don't send real emails in tests
@Test
@Transactional
void shouldCreateOrderAndAttemptEmail() {
Order order = orderService.create(validRequest());
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
verify(emailService).sendConfirmation(any(Order.class));
}
}
Application Context Caching
Spring caches the ApplicationContext between tests with the same configuration. Tests with different @MockBean, @SpringBootTest parameters, or different profiles get fresh contexts.
Minimize context variations to maximize cache hits:
// DO: share a base configuration
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class IntegrationTestBase {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");
// Common @MockBeans shared across all test classes
@MockBean
protected EmailService emailService;
}
class OrderIntegrationTest extends IntegrationTestBase {
// Uses the same context as PaymentIntegrationTest
}
class PaymentIntegrationTest extends IntegrationTestBase {
// Same context — no re-initialization
}
Testing Flyway Migrations
@SpringBootTest
@Testcontainers
class MigrationIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired Flyway flyway;
@Test
void allMigrationsShouldSucceed() {
MigrationInfoService info = flyway.info();
assertThat(info.applied()).isNotEmpty();
assertThat(Arrays.stream(info.applied()))
.allMatch(m -> m.getState() == MigrationState.SUCCESS);
}
@Test
void noMigrationsShouldBePending() {
assertThat(flyway.info().pending()).isEmpty();
}
}
Complete Integration Test Example
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class OrderLifecycleIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");
@Autowired TestRestTemplate restTemplate;
@Autowired OrderRepository orderRepository;
@MockBean EmailService emailService;
private UUID customerId;
@BeforeEach
void setup() {
customerId = UUID.randomUUID();
orderRepository.deleteAll();
}
@Test
void orderLifecycle_createConfirmShip() {
// Step 1: Create
var createReq = new CreateOrderRequest(customerId,
List.of(new OrderItemRequest(UUID.randomUUID(), 2)));
ResponseEntity<OrderResponse> created = restTemplate
.withBasicAuth("user", "password")
.postForEntity("/api/orders", createReq, OrderResponse.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
UUID orderId = created.getBody().id();
// Step 2: Confirm
ResponseEntity<OrderResponse> confirmed = restTemplate
.withBasicAuth("manager", "password")
.exchange("/api/orders/{id}/confirm", HttpMethod.PUT,
null, OrderResponse.class, orderId);
assertThat(confirmed.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(confirmed.getBody().status()).isEqualTo(OrderStatus.CONFIRMED);
// Step 3: Verify in DB
Order dbOrder = orderRepository.findById(orderId).orElseThrow();
assertThat(dbOrder.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
// Step 4: Verify email was sent
verify(emailService).sendConfirmation(any(Order.class));
}
}
What You’ve Learned
@SpringBootTest(webEnvironment = RANDOM_PORT)starts the full application on a random port@ServiceConnectionauto-configures datasource from a Testcontainers container — cleaner than@DynamicPropertySource- Test isolation:
@Transactionalrollback (unit-style),@AfterEachcleanup, or table truncation TestRestTemplatemakes real HTTP requests through the embedded server@MockBeanreplaces beans in the integration test context — use sparingly- Share base classes to maximize ApplicationContext cache hits across test classes
Next: Article 33 — Testing Secured Endpoints — the final article in Part 5, completing the tutorial series foundation.