Contents
- Test Slices — @WebMvcTest, @DataJpaTest, @JsonTest
- @MockBean vs @SpyBean vs Plain Mockito
- @TestConfiguration & @Import
- WireMock — Stubbing HTTP Dependencies
- Testcontainers — Real Database in Tests
- Testcontainers Reuse & Singleton Pattern
- Application Context Caching
- Speed & Isolation Best Practices
Sliced test annotations load only a subset of the application context, making tests much faster than @SpringBootTest while still exercising the real Spring infrastructure for that layer.
// @WebMvcTest — loads only MVC layer: controllers, filters, @ControllerAdvice
// Does NOT start a real server, does NOT load @Service or @Repository beans
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService orderService; // must be provided — not loaded by slice
@Test
void getOrder_returns200() throws Exception {
given(orderService.findById(1L)).willReturn(Order.of(1L, "PENDING"));
mvc.perform(get("/orders/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void getOrder_notFound_returns404() throws Exception {
given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));
mvc.perform(get("/orders/99"))
.andExpect(status().isNotFound());
}
}
// @DataJpaTest — loads JPA layer only: entities, repositories, datasource
// Rolls back each test by default, uses H2 unless you override
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // use real DB
@Import(TestcontainersConfig.class) // bring in Testcontainers PostgreSQL (see below)
class ProductRepositoryTest {
@Autowired ProductRepository repo;
@Test
void findByCategory_returnsMatchingProducts() {
repo.save(new Product("Widget", "ELECTRONICS", 9.99));
repo.save(new Product("Gadget", "ELECTRONICS", 19.99));
repo.save(new Product("Book", "BOOKS", 4.99));
List<Product> electronics = repo.findByCategory("ELECTRONICS");
assertThat(electronics).hasSize(2);
}
}
// @JsonTest — loads only Jackson / JSON serialization
@JsonTest
class OrderDtoJsonTest {
@Autowired JacksonTester<OrderDto> json;
@Test
void serialize() throws Exception {
OrderDto dto = new OrderDto(1L, "PENDING", BigDecimal.valueOf(99.99));
assertThat(json.write(dto)).hasJsonPathNumberValue("$.id", 1);
assertThat(json.write(dto)).extractingJsonPathStringValue("$.status").isEqualTo("PENDING");
}
@Test
void deserialize() throws Exception {
String content = """
{"id":1,"status":"PENDING","total":99.99}
""";
assertThat(json.parse(content).getObject().status()).isEqualTo("PENDING");
}
}
// @MockBean — replaces the Spring bean with a Mockito mock
// Use when: you want full control over a dependency and don't need any real behaviour
@WebMvcTest(CheckoutController.class)
class CheckoutControllerTest {
@MockBean PaymentService paymentService; // complete mock — all methods return null/0/false
@MockBean InventoryService inventoryService;
@Test
void checkout_chargesPayment() throws Exception {
given(inventoryService.reserve(any())).willReturn(true);
given(paymentService.charge(any())).willReturn(PaymentResult.success("txn-123"));
mvc.perform(post("/checkout").contentType(APPLICATION_JSON).content("""
{"cartId":"cart-1","paymentToken":"tok_visa"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.transactionId").value("txn-123"));
}
}
// @SpyBean — wraps a real Spring bean with Mockito spy
// Use when: you want the real implementation but override only specific methods
@SpringBootTest
class NotificationServiceTest {
@SpyBean EmailService emailService; // real EmailService, but we can stub/verify
@Test
void sendWelcome_callsEmailService() {
// Only stub the method that would send a real email
doNothing().when(emailService).send(any());
notificationService.sendWelcome("user@example.com");
verify(emailService).send(argThat(msg ->
msg.to().equals("user@example.com") && msg.subject().contains("Welcome")));
}
}
// Plain Mockito (no Spring) — fastest for pure unit tests
class OrderServiceTest {
OrderRepository repo = Mockito.mock(OrderRepository.class);
OrderService sut = new OrderService(repo); // constructor injection
@Test
void create_savesAndReturnsOrder() {
Order saved = new Order(1L, "NEW");
when(repo.save(any())).thenReturn(saved);
Order result = sut.create(new CreateOrderRequest("item-1"));
assertThat(result.id()).isEqualTo(1L);
}
}
Every @MockBean or @SpyBean in a test class causes Spring to create a new application context — destroying the cached context and slowing your suite. Group tests with identical context shapes into the same class, or use @TestConfiguration to avoid context pollution.
// @TestConfiguration — defines beans available only in tests
// NOT picked up by component scan unless @Import-ed explicitly
@TestConfiguration
public class TestcontainersConfig {
@Bean
@ServiceConnection // Spring Boot 3.1+ auto-wires connection properties
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
}
}
// Import into a test class
@DataJpaTest
@Import(TestcontainersConfig.class)
class UserRepositoryTest { /* ... */ }
// Sharing a @TestConfiguration across multiple test classes
// Create a base test class annotated with @SpringBootTest + @Import
@SpringBootTest
@Import(TestcontainersConfig.class)
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
// common test utilities
}
class OrderIT extends AbstractIntegrationTest {
@Test void createOrder() { /* ... */ }
}
Use WireMock to stub external HTTP services (payment gateways, third-party APIs, microservices). It starts a real HTTP server on a random port so your WebClient / RestTemplate / HttpClient code runs unchanged.
// Add dependency: org.springframework.cloud:spring-cloud-contract-wiremock
// or: com.github.tomakehurst:wiremock-jre8-standalone
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0) // Spring Cloud Contract WireMock — random port
class PaymentClientTest {
@Autowired PaymentClient paymentClient; // uses ${wiremock.server.port} property
@Test
void charge_success() {
// Stub the external payment API
stubFor(post(urlEqualTo("/v1/charges"))
.withHeader("Authorization", matching("Bearer .*"))
.withRequestBody(matchingJsonPath("$.amount"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"id":"ch_123","status":"succeeded","amount":9999}
""")));
ChargeResult result = paymentClient.charge("tok_visa", 9999);
assertThat(result.id()).isEqualTo("ch_123");
assertThat(result.status()).isEqualTo("succeeded");
verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
}
@Test
void charge_networkError_throwsException() {
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
assertThatThrownBy(() -> paymentClient.charge("tok_bad", 100))
.isInstanceOf(PaymentException.class);
}
}
// Standalone WireMock server (no Spring Cloud Contract)
@ExtendWith(WireMockExtension.class)
class ShippingClientTest {
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
ShippingClient client;
@BeforeEach
void setup() {
client = new ShippingClient("http://localhost:" + wm.getPort());
}
@Test
void getRate_returnsRate() {
wm.stubFor(get("/rates?from=10001&to=90210")
.willReturn(okJson("""{"rate":12.99,"carrier":"UPS"}""")));
Rate rate = client.getRate("10001", "90210");
assertThat(rate.carrier()).isEqualTo("UPS");
}
}
// Add: org.springframework.boot:spring-boot-testcontainers (Spring Boot 3.1+)
// and: org.testcontainers:postgresql
@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
@ServiceConnection // auto-configures spring.datasource.* from the container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired OrderRepository orderRepo;
@Test
@Transactional
void saveAndFind() {
Order order = orderRepo.save(new Order(null, "NEW", BigDecimal.TEN));
assertThat(order.id()).isNotNull();
Optional<Order> found = orderRepo.findById(order.id());
assertThat(found).isPresent();
assertThat(found.get().status()).isEqualTo("NEW");
}
}
// Useful container types
new PostgreSQLContainer<>("postgres:16-alpine");
new MySQLContainer<>("mysql:8.3");
new MongoDBContainer("mongo:7");
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
.withServices(S3, SQS);
Starting a new container for every test class is slow. Use the singleton pattern to share one container across the entire test suite.
// AbstractContainerBaseTest.java — shared by all integration tests
public abstract class AbstractContainerBaseTest {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // requires testcontainers.reuse.enable=true in ~/.testcontainers.properties
POSTGRES.start();
}
}
// Or with Spring Boot 3.1 @ServiceConnection in a shared @TestConfiguration:
@TestConfiguration(proxyBeanMethods = false)
public class SharedTestContainers {
@Bean
@ServiceConnection
@Scope("singleton")
PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
With withReuse(true), Testcontainers reuses a running container across test runs (identified by a hash of the container config). This dramatically speeds up local development — container startup is paid only once.
Spring's test framework caches application contexts keyed by their configuration. Understanding what breaks the cache is critical for fast test suites.
// These invalidate the context cache (each creates a NEW context):
// 1. Different @MockBean / @SpyBean declarations
// 2. @DirtiesContext on a test class or method
// 3. Different @ActiveProfiles
// 4. Different @TestPropertySource values
// 5. Different @ContextConfiguration classes
// ✅ Good — same context shared across all three test classes
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceIT { /* @MockBean PaymentService */ }
@SpringBootTest
@ActiveProfiles("test")
class UserServiceIT { /* @MockBean PaymentService — SAME mock, context reused */ }
// ❌ Bad — each class gets its own context
@SpringBootTest
class OrderServiceIT { @MockBean PaymentService p; }
@SpringBootTest
class UserServiceIT { @MockBean EmailService e; } // different beans → new context
// ✅ Fix — collect all @MockBean declarations in one base class
@SpringBootTest
@ActiveProfiles("test")
abstract class BaseIT {
@MockBean PaymentService paymentService;
@MockBean EmailService emailService;
}
class OrderServiceIT extends BaseIT { }
class UserServiceIT extends BaseIT { }
- Prefer sliced tests (@WebMvcTest, @DataJpaTest) over @SpringBootTest wherever possible — they start in milliseconds.
- Unit test service logic with plain Mockito — no Spring context needed for business logic.
- Use @Transactional on test methods for @DataJpaTest and integration tests to roll back data after each test.
- One Testcontainers instance per suite — use the singleton pattern or @ServiceConnection with a shared @TestConfiguration.
- Parallelise at the class level — configure junit.jupiter.execution.parallel.enabled=true in junit-platform.properties, but ensure containers and context-sensitive state are thread-safe.
- Avoid @DirtiesContext — it forces a full context reload. Use @Transactional rollback or explicit data cleanup instead.
- Keep test profiles minimal — a single test profile for all integration tests maximises context cache reuse.