Skip to main content
spring internals

Slice Tests and Testcontainers Integration

7 min read Chapter 69 of 78

Slice Tests and Testcontainers Integration

A slice test loads only the auto-configuration relevant to one layer of the application. Where @SpringBootTest boots every bean in the SaaS backend, @WebMvcTest boots only the web layer: controllers, argument resolvers, exception handlers, and MockMvc. The context starts in 1 to 3 seconds instead of 10 to 15. More importantly, slice tests fail when a controller accidentally depends on something outside the web layer, enforcing architectural boundaries at test time.

@WebMvcTest: Testing Controllers in Isolation

@WebMvcTest loads the Spring MVC infrastructure and the specified controller. Everything else is excluded. Services, repositories, and external clients must be provided as @MockBean declarations.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @MockBean
    private TenantContextHolder tenantContextHolder;

    @Test
    void shouldReturn201WhenOrderCreated() throws Exception {
        when(tenantContextHolder.getCurrentTenantId())
            .thenReturn("tenant-1");
        when(orderService.createOrder(any()))
            .thenReturn(new Order("order-1", OrderStatus.CONFIRMED));

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .header("X-Tenant-Id", "tenant-1")
                .content("""
                    {
                        "sku": "SKU-100",
                        "quantity": 5
                    }
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value("order-1"))
            .andExpect(jsonPath("$.status").value("CONFIRMED"));
    }

    @Test
    void shouldReturn400WhenSkuMissing() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .header("X-Tenant-Id", "tenant-1")
                .content("""
                    {
                        "quantity": 5
                    }
                    """))
            .andExpect(status().isBadRequest());
    }
}

The @WebMvcTest(OrderController.class) annotation triggers WebMvcTestContextBootstrapper, which registers only these auto-configurations: WebMvcAutoConfiguration, JacksonAutoConfiguration, HttpMessageConvertersAutoConfiguration, and a few others. The full list is defined in META-INF/spring/org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc.imports.

@MockBean in a slice test context does create a MockitoContextCustomizer, but the context is small enough that the cost of creating multiple slice contexts is negligible. Three different @WebMvcTest classes with different controllers and mocks produce three contexts that each take 1 to 2 seconds. Total overhead: 5 seconds. Acceptable.

@DataJpaTest: Testing Repositories

@DataJpaTest loads JPA, Hibernate, DataSource, and an embedded database (H2 by default). It does not load controllers, services, or security.

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindOrdersByTenant() {
        entityManager.persist(
            new OrderEntity("tenant-1", "SKU-100", 5, OrderStatus.CONFIRMED)
        );
        entityManager.persist(
            new OrderEntity("tenant-1", "SKU-200", 3, OrderStatus.PENDING)
        );
        entityManager.persist(
            new OrderEntity("tenant-2", "SKU-100", 1, OrderStatus.CONFIRMED)
        );
        entityManager.flush();

        List<OrderEntity> orders =
            orderRepository.findByTenantId("tenant-1");

        assertThat(orders).hasSize(2);
        assertThat(orders)
            .extracting(OrderEntity::getTenantId)
            .containsOnly("tenant-1");
    }
}

Each @DataJpaTest method runs in a transaction that rolls back after the test. No cleanup needed. The TestEntityManager provides a JPA-aware wrapper around EntityManager with convenience methods like persistAndFlush().

The H2 Trap

Here is where teams get burned. The SaaS backend uses PostgreSQL in production. H2 in test mode silently accepts syntax and behaviors that PostgreSQL rejects:

// BROKEN: @DataJpaTest with H2 when production uses PostgreSQL

@DataJpaTest
class TenantPartitionRepositoryTest {

    @Autowired
    private TenantPartitionRepository repository;

    @Test
    void shouldQueryWithPartitionPruning() {
        // This test passes with H2 but the query uses
        // PostgreSQL-specific syntax: partition pruning, JSONB operators.
        // H2 silently ignores or misinterprets them.

        // Native query with PostgreSQL syntax
        List<TenantData> data = repository
            .findByPartitionKey("tenant-1", 2024);
        // Passes on H2: the native query happens to work by accident.
        // Fails on PostgreSQL: H2 does not enforce CHECK constraints
        // the same way, and JSONB operators do not exist in H2.

        assertThat(data).isNotEmpty();
    }
}

The test passes in CI. The code fails in staging. The fix is Testcontainers.

Testcontainers: Real Databases in Tests

Testcontainers starts a Docker container with the actual database engine. The test runs against PostgreSQL, not an in-memory approximation.

Basic Setup

@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class TenantPartitionRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("saas_test")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private TenantPartitionRepository repository;

    @Test
    void shouldQueryWithPartitionPruning() {
        // Now running against real PostgreSQL.
        // PostgreSQL-specific syntax is validated.
        // JSONB operators work correctly.
        List<TenantData> data = repository
            .findByPartitionKey("tenant-1", 2024);
        assertThat(data).isNotEmpty();
    }
}

@AutoConfigureTestDatabase(replace = Replace.NONE) tells Spring Boot not to replace the datasource with an embedded database. Without this, @DataJpaTest will override the Testcontainers datasource with H2.

@DynamicPropertySource registers datasource properties at runtime, after the container has started and the dynamic port is known.

@ServiceConnection: The Clean Approach

Spring Boot 3.1 introduced @ServiceConnection, which eliminates the @DynamicPropertySource boilerplate:

// CORRECT: Testcontainers with @ServiceConnection

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldPersistOrderWithTenantId() {
        entityManager.persist(
            new OrderEntity("tenant-1", "SKU-100", 5, OrderStatus.CONFIRMED)
        );
        entityManager.flush();

        Optional<OrderEntity> found =
            orderRepository.findById("tenant-1", "order-1");
        assertThat(found).isPresent();
    }
}

@ServiceConnection on the container field tells Spring Boot to auto-detect the connection type (JDBC in this case) and configure the datasource properties automatically. No manual property registration. No risk of typos in property names.

The mechanism works through ContainerConnectionDetailsFactory implementations. For PostgreSQL, JdbcContainerConnectionDetailsFactory creates a JdbcConnectionDetails that the auto-configuration uses to build the DataSource. This plugs into the same ConnectionDetails abstraction that Spring Boot uses for service bindings in production (e.g., Kubernetes service bindings).

Sharing Containers Across Test Classes

Starting a PostgreSQL container takes 2 to 5 seconds. If every @DataJpaTest class starts its own container, the overhead adds up. Use a shared container:

public abstract class SharedPostgresTest {

    @Container
    @ServiceConnection
    protected static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true);
}

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest extends SharedPostgresTest {
    // Uses the shared container
}

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class InventoryRepositoryTest extends SharedPostgresTest {
    // Reuses the same container
}

The .withReuse(true) flag keeps the container running between test classes and even between test suite executions (if testcontainers.reuse.enable=true is set in ~/.testcontainers.properties). The container persists until manually stopped or until Docker cleans it up.

@WebFluxTest: Reactive Controller Testing

For the SaaS gateway module built on WebFlux (CH16):

@WebFluxTest(TenantGatewayController.class)
class TenantGatewayControllerTest {

    @Autowired
    private WebTestClient webTestClient;

    @MockBean
    private TenantRoutingService routingService;

    @Test
    void shouldRouteRequestToCorrectTenant() {
        when(routingService.resolveBackend("tenant-1"))
            .thenReturn(Mono.just(URI.create("http://tenant-1.internal:8080")));

        webTestClient.get()
            .uri("/api/data")
            .header("X-Tenant-Id", "tenant-1")
            .exchange()
            .expectStatus().isOk();
    }
}

@WebFluxTest loads WebFluxAutoConfiguration and configures WebTestClient instead of MockMvc. It does not load @Controller beans annotated with @RestController from the MVC stack. Only @RestController classes built on WebFlux are included.

Custom Slice Tests

The SaaS backend has an audit starter (saas-audit-spring-boot-starter) that auto-configures an AuditEventRepository and an AuditInterceptor. Testing this starter in isolation requires a custom slice:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@OverrideAutoConfiguration(enabled = false)
@ImportAutoConfiguration
public @interface AuditSliceTest {
}

The @ImportAutoConfiguration without explicit classes triggers Spring Boot to look for a file named after the annotation:

# src/test/resources/META-INF/spring/
#   com.saas.test.AuditSliceTest.imports
com.saas.audit.autoconfigure.AuditAutoConfiguration
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration

This file lists exactly which auto-configurations the slice should load. Nothing more, nothing less.

@AuditSliceTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class AuditEventRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private AuditEventRepository auditRepository;

    @Test
    void shouldPersistAuditEvent() {
        AuditEvent event = new AuditEvent(
            "tenant-1", "ORDER_CREATED",
            Map.of("orderId", "order-42", "userId", "user-7")
        );

        auditRepository.save(event);

        List<AuditEvent> events =
            auditRepository.findByTenantId("tenant-1");
        assertThat(events).hasSize(1);
        assertThat(events.get(0).getEventType())
            .isEqualTo("ORDER_CREATED");
    }
}

The custom slice loads only audit and JPA auto-configurations. No web layer, no security, no messaging. The context starts in under 2 seconds. Combine this with Testcontainers for a production-accurate test that runs fast.

Choosing the Right Test Type

Decision framework for the SaaS backend:

QuestionAnswerTest type
Testing a controller’s HTTP behavior?Yes@WebMvcTest or @WebFluxTest
Testing a JPA repository query?Yes@DataJpaTest + Testcontainers
Testing JSON serialization?Yes@JsonTest
Testing a service with multiple dependencies?Yes@SpringBootTest with @SaaSIntegrationTest
Testing a custom auto-configuration?YesCustom slice
Need the full application running?Yes@SpringBootTest with RANDOM_PORT

The goal is a test pyramid:

  • Many @WebMvcTest and @DataJpaTest tests (fast, focused)
  • Fewer @SpringBootTest integration tests (slow, comprehensive)
  • A handful of end-to-end tests (slowest, full stack)

Each layer catches different classes of bugs. Slice tests catch contract violations and query errors. Integration tests catch wiring problems and cross-cutting concerns like tenant isolation. Neither replaces the other. The combination, with context caching and Testcontainers, keeps the full suite under 5 minutes for the SaaS backend’s 400 tests.