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

OptionWhat it startsUse for
RANDOM_PORTEmbedded server on random portFull HTTP round-trip tests
DEFINED_PORTEmbedded server on configured portWhen you need a fixed port
MOCK (default)No real server, MockMvc availableFast tests without real HTTP
NONENo server at allService/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
  • @ServiceConnection auto-configures datasource from a Testcontainers container — cleaner than @DynamicPropertySource
  • Test isolation: @Transactional rollback (unit-style), @AfterEach cleanup, or table truncation
  • TestRestTemplate makes real HTTP requests through the embedded server
  • @MockBean replaces 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.