
Spring Boot Testing
- 1 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Provides Spring Boot 4 testing patterns: slice tests, Testcontainers with @ServiceConnection, security testing, and the @MockBean to @MockitoBean migration.
About
Covers Spring Boot 4 testing including unit and slice tests, Testcontainers, security testing, and Modulith Scenario API, plus the critical @MockitoBean migration. A developer uses it when writing tests for a Spring Boot 4 application.
- @MockBean to @MockitoBean and MockMvcTester migration
- Slice tests, Testcontainers @ServiceConnection, security testing
Spring Boot Testing by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill spring-boot-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Provides Spring Boot 4 testing patterns: slice tests, Testcontainers with @ServiceConnection, security testing, and the @MockBean to @MockitoBean migration.
Files
Spring Boot 4 Testing
Comprehensive testing patterns including slice tests, Testcontainers, security testing, and Modulith Scenario API.
Critical Breaking Change
| Old (Boot 3.x) | New (Boot 4.x) | Notes |
|---|---|---|
@MockBean | @MockitoBean | Required migration |
@SpyBean | @MockitoSpyBean | Required migration |
MockMvc (procedural) | MockMvcTester (fluent) | New AssertJ-style API |
Implicit @AutoConfigureMockMvc | Explicit annotation required | Add to @SpringBootTest |
MockMvcTester (Spring Boot 4)
New fluent, AssertJ-style API for controller testing:
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvcTester mvc; // NEW: Fluent API
@Test
void getUser_returnsUser() {
mvc.get().uri("/users/{id}", 1)
.exchange()
.assertThat()
.hasStatusOk()
.bodyJson()
.extractingPath("$.name")
.isEqualTo("John");
}
}Key Benefits: Fluent assertions, better error messages, AssertJ integration.
Test Annotation Selection
| Test Type | Annotation | Use When |
|---|---|---|
| Controller | @WebMvcTest | Testing request/response, validation |
| Repository | @DataJpaTest | Testing queries, entity mapping |
| JSON | @JsonTest | Testing serialization/deserialization |
| REST Client | @RestClientTest | Testing external API clients |
| Full Integration | @SpringBootTest | End-to-end, with real dependencies |
| Module | @ApplicationModuleTest | Testing bounded context in isolation |
Core Workflow
1. Choose test slice → Minimal context for fast tests 2. Mock dependencies → @MockitoBean for external services 3. Use Testcontainers → @ServiceConnection for databases 4. Assert thoroughly → Use AssertJ, MockMvcTester (new), RestTestClient (new), WebTestClient 5. Test security → @WithMockUser, JWT mocking
Quick Patterns
See EXAMPLES.md for complete working examples including:
- @WebMvcTest with
MockMvcTesterand@MockitoBean(Java + Kotlin) - @DataJpaTest with
TestEntityManagerfor lazy loading verification - Testcontainers with
@ServiceConnectionfor PostgreSQL/Redis - Security Testing with
@WithMockUserfor role-based access - Modulith Event Testing with
ScenarioAPI
Detailed References
- Examples: See EXAMPLES.md for complete working code examples
- Troubleshooting: See TROUBLESHOOTING.md for common issues and Boot 4 migration
- Slice Tests: See references/SLICE-TESTS.md for @WebMvcTest, @DataJpaTest, @JsonTest patterns
- Testcontainers: See references/TESTCONTAINERS.md for @ServiceConnection, container reuse
- Security Testing: See references/SECURITY-TESTING.md for @WithMockUser, JWT mocking
- Modulith Testing: See references/MODULITH-TESTING.md for Scenario API, event verification
Anti-Pattern Checklist
| Anti-Pattern | Fix |
|---|---|
Using @MockBean in Boot 4 | Replace with @MockitoBean |
@SpringBootTest for unit tests | Use appropriate slice annotation |
Missing entityManager.clear() | Add to verify lazy loading |
| High-cardinality test data | Use minimal, focused fixtures |
| Shared mutable test state | Use @DirtiesContext or fresh containers |
| No security tests | Add @WithMockUser tests for endpoints |
Related Skills
| Need | Skill |
|---|---|
| Security configuration | spring-boot-security |
| Module boundaries | spring-boot-modulith |
| Data layer patterns | spring-boot-data-ddd |
| Controller patterns | spring-boot-web-api |
Critical Reminders
1. @MockitoBean is mandatory — @MockBean removed in Boot 4 2. Slice tests are fast — Use them for focused testing 3. Clear EntityManager — Required to test lazy loading behavior 4. @ServiceConnection simplifies Testcontainers — No more @DynamicPropertySource 5. Test security explicitly — Don't rely on disabled security
Spring Boot Testing Examples
Complete working examples for Spring Boot 4 testing patterns.
@WebMvcTest with @MockitoBean
Controller slice test using the new MockMvcTester and @MockitoBean (replaces deprecated @MockBean).
Java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvcTester mvc; // New in Boot 4
@MockitoBean // Replaces @MockBean
private OrderService orderService;
@Test
void shouldReturnOrder() {
given(orderService.findById(1L))
.willReturn(Optional.of(new Order(1L, "SUBMITTED")));
assertThat(mvc.get().uri("/api/orders/1"))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson()
.extractingPath("$.status").isEqualTo("SUBMITTED");
}
@Test
void shouldReturn404WhenNotFound() {
given(orderService.findById(999L)).willReturn(Optional.empty());
assertThat(mvc.get().uri("/api/orders/999"))
.hasStatus(HttpStatus.NOT_FOUND);
}
}Kotlin
@WebMvcTest(OrderController::class)
class OrderControllerTest(@Autowired val mvc: MockMvcTester) {
@MockitoBean
lateinit var orderService: OrderService
@Test
fun `should return order`() {
given(orderService.findById(1L))
.willReturn(Optional.of(Order(1L, "SUBMITTED")))
assertThat(mvc.get().uri("/api/orders/1"))
.hasStatusOk()
.bodyJson()
.extractingPath("$.status").isEqualTo("SUBMITTED")
}
}Key points:
MockMvcTesterprovides fluent assertions built on AssertJ@MockitoBeanis mandatory in Boot 4 (replaces@MockBean)- Use
bodyJson().extractingPath()for JSON assertions
---
@DataJpaTest with TestEntityManager
Repository slice test with entity persistence and lazy loading verification.
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrdersWithItems() {
Order order = new Order(LocalDateTime.now());
order.addItem(new OrderItem("Widget", 2));
entityManager.persistAndFlush(order);
entityManager.clear(); // Force re-fetch to verify lazy loading
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(1);
}
}Key points:
- Use
persistAndFlush()to ensure entity is in database - Call
entityManager.clear()before assertions to verify lazy loading works @DataJpaTestauto-configures in-memory database by default
---
Testcontainers with @ServiceConnection
Full integration test with real PostgreSQL and Redis containers.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection // Boot 4: auto-configures datasource
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
@ServiceConnection // Boot 4: auto-configures Redis
static RedisContainer redis = new RedisContainer("redis:7");
@Autowired
private WebTestClient webClient;
@Test
void shouldCreateOrder() {
webClient.post()
.uri("/api/orders")
.bodyValue(new CreateOrderRequest("Widget", 5))
.exchange()
.expectStatus().isCreated()
.expectBody()
.jsonPath("$.id").isNotEmpty();
}
}Key points:
@ServiceConnectioneliminates need for@DynamicPropertySource- Static containers are shared across tests in the class
- Use
WebTestClientfor reactive-style assertions
---
Security Testing
Test authentication and authorization with @WithMockUser.
@WebMvcTest(AdminController.class)
class AdminControllerSecurityTest {
@Autowired
private MockMvcTester mvc;
@Test
void shouldRejectUnauthenticated() {
assertThat(mvc.get().uri("/api/admin/users"))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
@Test
@WithMockUser(roles = "USER")
void shouldRejectNonAdmin() {
assertThat(mvc.get().uri("/api/admin/users"))
.hasStatus(HttpStatus.FORBIDDEN);
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldAllowAdmin() {
assertThat(mvc.get().uri("/api/admin/users"))
.hasStatusOk();
}
}Key points:
- Test unauthenticated, wrong role, and correct role scenarios
@WithMockUserprovides a mockAuthenticationobject- Default user has
ROLE_USERif not specified
---
Modulith Event Testing with Scenario API
Test event publishing and handling in Spring Modulith bounded contexts.
@ApplicationModuleTest
class OrderModuleTest {
@Autowired
private OrderService orderService;
@Test
void shouldPublishOrderCreatedEvent(Scenario scenario) {
scenario.stimulate(() -> orderService.createOrder(request))
.andWaitForEventOfType(OrderCreated.class)
.toArriveAndVerify(event -> {
assertThat(event.orderId()).isNotNull();
assertThat(event.customerId()).isEqualTo("customer-123");
});
}
}Key points:
@ApplicationModuleTestloads only the module under testScenariois injected as a test parameter- Use
stimulate()to trigger actions andandWaitForEventOfType()to verify events - Events are captured asynchronously with configurable timeout
Modulith Testing
@ApplicationModuleTest, Scenario API, and event verification.
Table of Contents
- @ApplicationModuleTest
- Basic Usage
- Kotlin
- Bootstrap Modes
- Scenario API
- Verify Event Publication
- Kotlin Scenario
- Verify State Changes
- Multiple Events
- Event Matching
- Timeout Configuration
- Testing Event Handlers
- Publish Event Directly
- Verify Handler Called
- Module Structure Verification
- Testing with Testcontainers
- Testing Event Externalization
- Custom Test Scenario Configuration
- Testing Sagas / Process Managers
- Best Practices
@ApplicationModuleTest
Tests a single module in isolation with controlled dependencies.
Basic Usage
package com.example.order;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;
@ApplicationModuleTest
class OrderModuleTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
Order order = orderService.create(new CreateOrderRequest("customer-123"));
assertThat(order.getId()).isNotNull();
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
}
}Kotlin
package com.example.order
import org.springframework.modulith.test.ApplicationModuleTest
import org.springframework.modulith.test.Scenario
@ApplicationModuleTest
class OrderModuleTest(@Autowired val orderService: OrderService) {
@Test
fun `should create order`() {
val order = orderService.create(CreateOrderRequest("customer-123"))
assertThat(order.id).isNotNull()
assertThat(order.status).isEqualTo(OrderStatus.DRAFT)
}
}Bootstrap Modes
| Mode | Loads | Use When |
|---|---|---|
STANDALONE | Current module only | Unit-like testing |
DIRECT_DEPENDENCIES | Module + direct dependencies | Most common |
ALL_DEPENDENCIES | Full dependency tree | Integration testing |
@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES)
class OrderModuleWithDependenciesTest {
@Autowired
private OrderService orderService;
@Autowired
private InventoryService inventoryService; // Direct dependency available
// ShippingService NOT available if not direct dependency
}Scenario API
Fluent API for testing asynchronous event-driven behavior.
Verify Event Publication
@ApplicationModuleTest
class OrderEventTest {
@Autowired
private OrderService orderService;
@Test
void shouldPublishOrderCreatedEvent(Scenario scenario) {
CreateOrderRequest request = new CreateOrderRequest("customer-123", "Widget", 5);
scenario.stimulate(() -> orderService.create(request))
.andWaitForEventOfType(OrderCreated.class)
.toArriveAndVerify(event -> {
assertThat(event.customerId()).isEqualTo("customer-123");
assertThat(event.productName()).isEqualTo("Widget");
assertThat(event.quantity()).isEqualTo(5);
});
}
}Kotlin Scenario
@ApplicationModuleTest
class OrderEventTest(@Autowired val orderService: OrderService) {
@Test
fun `should publish order created event`(scenario: Scenario) {
val request = CreateOrderRequest("customer-123", "Widget", 5)
scenario.stimulate { orderService.create(request) }
.andWaitForEventOfType(OrderCreated::class.java)
.toArriveAndVerify { event ->
assertThat(event.customerId).isEqualTo("customer-123")
assertThat(event.quantity).isEqualTo(5)
}
}
}Verify State Changes
@ApplicationModuleTest
class InventoryEventHandlerTest {
@Autowired
private StockRepository stockRepository;
@Autowired
private ApplicationEventPublisher eventPublisher;
@Test
void shouldDecrementStockOnOrderCreated(Scenario scenario) {
// Setup initial stock
stockRepository.save(new Stock("product-123", 100));
// Publish event
OrderCreated event = new OrderCreated(1L, "customer-1", "product-123", 5);
scenario.publish(event)
.andWaitForStateChange(() -> stockRepository.findByProductId("product-123"))
.andVerify(stock -> {
assertThat(stock.getQuantity()).isEqualTo(95);
});
}
}Multiple Events
@Test
void shouldPublishMultipleEvents(Scenario scenario) {
scenario.stimulate(() -> orderService.submitOrder(orderId))
.andWaitForEventOfType(OrderSubmitted.class)
.toArrive()
.andWaitForEventOfType(PaymentRequested.class)
.toArriveAndVerify(event -> {
assertThat(event.orderId()).isEqualTo(orderId);
});
}Event Matching
@Test
void shouldMatchSpecificEvent(Scenario scenario) {
scenario.stimulate(() -> orderService.createOrders(requests))
.andWaitForEventOfType(OrderCreated.class)
.matching(event -> event.customerId().equals("priority-customer"))
.toArriveAndVerify(event -> {
assertThat(event.priority()).isTrue();
});
}Timeout Configuration
@Test
void shouldCompleteWithinTimeout(Scenario scenario) {
scenario.stimulate(() -> orderService.processLargeOrder(request))
.andWaitForEventOfType(OrderProcessed.class)
.toArriveWithin(Duration.ofSeconds(10))
.andVerify(event -> {
assertThat(event.status()).isEqualTo("COMPLETED");
});
}Testing Event Handlers
Publish Event Directly
@ApplicationModuleTest
class NotificationEventHandlerTest {
@Autowired
private NotificationRepository notificationRepository;
@Test
void shouldCreateNotificationOnOrderShipped(Scenario scenario) {
OrderShipped event = new OrderShipped(
1L,
"customer-123",
"TRACK-12345"
);
scenario.publish(event)
.andWaitForStateChange(() ->
notificationRepository.findByCustomerId("customer-123"))
.andVerify(notifications -> {
assertThat(notifications).hasSize(1);
assertThat(notifications.get(0).getMessage())
.contains("TRACK-12345");
});
}
}Verify Handler Called
@ApplicationModuleTest
class AnalyticsEventHandlerTest {
@MockitoBean
private AnalyticsClient analyticsClient;
@Test
void shouldTrackOrderEvent(Scenario scenario) {
OrderCreated event = new OrderCreated(1L, "customer-123", "Widget", 5);
scenario.publish(event)
.andWaitForEventOfType(OrderCreated.class)
.toArrive();
verify(analyticsClient, timeout(5000)).track(
eq("order_created"),
argThat(props -> props.get("orderId").equals(1L))
);
}
}Module Structure Verification
class ModularityTests {
private static final ApplicationModules modules =
ApplicationModules.of(Application.class);
@Test
void shouldHaveNoCircularDependencies() {
modules.verify();
}
@Test
void shouldDocumentModules() {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
@Test
void shouldDetectAllModules() {
assertThat(modules.stream())
.extracting(ApplicationModule::getName)
.containsExactlyInAnyOrder(
"order",
"inventory",
"shipping",
"notification"
);
}
}Testing with Testcontainers
@ApplicationModuleTest
@Testcontainers
class OrderModuleIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@Autowired
private OrderService orderService;
@Test
void shouldPersistAndPublish(Scenario scenario) {
scenario.stimulate(() -> orderService.create(request))
.andWaitForEventOfType(OrderCreated.class)
.toArriveAndVerify(event -> {
// Verify persisted
assertThat(orderRepository.findById(event.orderId())).isPresent();
});
}
}Testing Event Externalization
@ApplicationModuleTest
@EmbeddedKafka
class EventExternalizationTest {
@Autowired
private OrderService orderService;
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private EmbeddedKafkaBroker embeddedKafka;
@Test
void shouldExternalizeEventToKafka(Scenario scenario) {
Consumer<String, String> consumer = createConsumer("orders-topic");
scenario.stimulate(() -> orderService.create(request))
.andWaitForEventOfType(OrderCreated.class)
.toArrive();
ConsumerRecords<String, String> records =
KafkaTestUtils.getRecords(consumer, Duration.ofSeconds(10));
assertThat(records.count()).isEqualTo(1);
assertThat(records.iterator().next().value())
.contains("\"type\":\"OrderCreated\"");
}
}Custom Test Scenario Configuration
@ApplicationModuleTest
@Import(TestScenarioConfig.class)
class CustomScenarioTest {
@Test
void shouldUseCustomTimeout(Scenario scenario) {
scenario.stimulate(() -> slowService.process())
.andWaitForEventOfType(ProcessCompleted.class)
.toArriveAndVerify(event -> {
assertThat(event.success()).isTrue();
});
}
}
@TestConfiguration
class TestScenarioConfig {
@Bean
public ScenarioCustomizer scenarioCustomizer() {
return ScenarioCustomizer.builder()
.defaultTimeout(Duration.ofSeconds(30))
.build();
}
}Testing Sagas / Process Managers
@ApplicationModuleTest
class OrderFulfillmentSagaTest {
@Autowired
private OrderService orderService;
@Test
void shouldCompleteFulfillmentSaga(Scenario scenario) {
// Start saga
scenario.stimulate(() -> orderService.submit(orderId))
// Wait for each step
.andWaitForEventOfType(OrderSubmitted.class)
.toArrive()
.andWaitForEventOfType(PaymentProcessed.class)
.toArrive()
.andWaitForEventOfType(InventoryReserved.class)
.toArrive()
.andWaitForEventOfType(ShipmentCreated.class)
.toArriveAndVerify(event -> {
assertThat(event.trackingNumber()).isNotNull();
});
// Verify final state
Order order = orderService.findById(orderId);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SHIPPED);
}
@Test
void shouldCompensateOnFailure(Scenario scenario) {
// Setup payment to fail
paymentService.setForceFail(true);
scenario.stimulate(() -> orderService.submit(orderId))
.andWaitForEventOfType(OrderSubmitted.class)
.toArrive()
.andWaitForEventOfType(PaymentFailed.class)
.toArrive()
.andWaitForEventOfType(OrderCancelled.class)
.toArriveAndVerify(event -> {
assertThat(event.reason()).contains("Payment failed");
});
// Verify compensating actions
Order order = orderService.findById(orderId);
assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
}
}Best Practices
1. Test module boundaries — Verify events cross modules correctly 2. Use Scenario for async — Don't use Thread.sleep() 3. Verify state changes — Not just event publication 4. Test failure paths — Compensating events, rollbacks 5. Keep modules small — Easier to test in isolation 6. Document with tests — Tests show how modules interact
Security Testing
@WithMockUser, JWT testing, and secured endpoint verification.
Table of Contents
- @WithMockUser
- Basic Usage
- Kotlin
- With Authorities (not roles)
- @WithUserDetails
- Custom Authentication Annotation
- JWT Testing
- Using jwt() Request Post-Processor
- With MockMvcTester (Boot 4)
- Custom JWT Authorities Converter in Test
- OAuth2 Login Testing
- Method Security Testing
- CSRF Testing
- Integration Test with Full Security
- Disable Security for Specific Tests
@WithMockUser
Simplest way to test authenticated endpoints.
Basic Usage
@WebMvcTest(OrderController.class)
class OrderControllerSecurityTest {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldRejectUnauthenticatedRequest() {
assertThat(mvc.get().uri("/api/orders"))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
@Test
@WithMockUser
void shouldAllowAuthenticatedUser() {
given(orderService.findAll()).willReturn(List.of());
assertThat(mvc.get().uri("/api/orders"))
.hasStatusOk();
}
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
void shouldAllowAdmin() {
assertThat(mvc.get().uri("/api/admin/orders"))
.hasStatusOk();
}
@Test
@WithMockUser(roles = {"USER"})
void shouldDenyNonAdminFromAdminEndpoint() {
assertThat(mvc.get().uri("/api/admin/orders"))
.hasStatus(HttpStatus.FORBIDDEN);
}
}Kotlin
@WebMvcTest(OrderController::class)
class OrderControllerSecurityTest(@Autowired val mvc: MockMvcTester) {
@MockitoBean
lateinit var orderService: OrderService
@Test
fun `should reject unauthenticated`() {
assertThat(mvc.get().uri("/api/orders"))
.hasStatus(HttpStatus.UNAUTHORIZED)
}
@Test
@WithMockUser(roles = ["ADMIN"])
fun `should allow admin`() {
assertThat(mvc.get().uri("/api/admin/orders"))
.hasStatusOk()
}
}With Authorities (not roles)
@Test
@WithMockUser(authorities = {"order:read", "order:write"})
void shouldAllowWithAuthorities() {
assertThat(mvc.get().uri("/api/orders"))
.hasStatusOk();
}@WithUserDetails
Uses actual UserDetailsService to load user.
@WebMvcTest(OrderController.class)
@Import(TestSecurityConfig.class)
class OrderControllerWithUserDetailsTest {
@Autowired
private MockMvcTester mvc;
@Test
@WithUserDetails("admin@example.com")
void shouldLoadUserFromService() {
assertThat(mvc.get().uri("/api/orders"))
.hasStatusOk();
}
}
@TestConfiguration
class TestSecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails admin = User.builder()
.username("admin@example.com")
.password("{noop}password")
.roles("ADMIN")
.build();
UserDetails user = User.builder()
.username("user@example.com")
.password("{noop}password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
}Custom Authentication Annotation
Create reusable security contexts:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAdminUserSecurityContextFactory.class)
public @interface WithAdminUser {
String username() default "admin@example.com";
String tenantId() default "default-tenant";
}
public class WithAdminUserSecurityContextFactory
implements WithSecurityContextFactory<WithAdminUser> {
@Override
public SecurityContext createSecurityContext(WithAdminUser annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomPrincipal principal = new CustomPrincipal(
annotation.username(),
annotation.tenantId()
);
Authentication auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
);
context.setAuthentication(auth);
return context;
}
}
// Usage
@Test
@WithAdminUser(tenantId = "tenant-123")
void shouldAccessTenantData() {
assertThat(mvc.get().uri("/api/tenant/data"))
.hasStatusOk();
}JWT Testing
Using jwt() Request Post-Processor
@WebMvcTest(ApiController.class)
class JwtSecuredControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldAcceptValidJwt() throws Exception {
mockMvc.perform(get("/api/resource")
.with(jwt()
.authorities(new SimpleGrantedAuthority("SCOPE_read"))
.jwt(jwt -> jwt
.subject("user@example.com")
.claim("scope", "read write")
.claim("tenant_id", "tenant-123"))))
.andExpect(status().isOk());
}
@Test
void shouldRejectMissingScope() throws Exception {
mockMvc.perform(get("/api/admin")
.with(jwt()
.authorities(new SimpleGrantedAuthority("SCOPE_read"))))
.andExpect(status().isForbidden());
}
@Test
void shouldRejectExpiredJwt() throws Exception {
mockMvc.perform(get("/api/resource")
.with(jwt()
.jwt(jwt -> jwt
.expiresAt(Instant.now().minusSeconds(3600)))))
.andExpect(status().isUnauthorized());
}
}With MockMvcTester (Boot 4)
@WebMvcTest(ApiController.class)
class JwtSecuredControllerTest {
@Autowired
private MockMvcTester mvc;
@Test
void shouldAcceptValidJwt() {
assertThat(mvc.get().uri("/api/resource")
.with(jwt()
.authorities(new SimpleGrantedAuthority("SCOPE_read"))))
.hasStatusOk();
}
}Custom JWT Authorities Converter in Test
@WebMvcTest(ApiController.class)
@Import(JwtTestConfig.class)
class JwtWithCustomClaimsTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldExtractCustomClaims() throws Exception {
mockMvc.perform(get("/api/resource")
.with(jwt()
.jwt(jwt -> jwt
.claim("permissions", List.of("order:read", "order:write"))
.claim("roles", List.of("admin", "user")))))
.andExpect(status().isOk());
}
}
@TestConfiguration
class JwtTestConfig {
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("permissions");
converter.setAuthorityPrefix("");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}OAuth2 Login Testing
@WebMvcTest(ProfileController.class)
class OAuth2LoginTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldAccessWithOAuth2Login() throws Exception {
mockMvc.perform(get("/profile")
.with(oauth2Login()
.attributes(attrs -> attrs.put("name", "John Doe"))
.authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isOk());
}
@Test
void shouldAccessWithOidcLogin() throws Exception {
mockMvc.perform(get("/profile")
.with(oidcLogin()
.idToken(token -> token
.claim("email", "user@example.com")
.claim("name", "John Doe"))))
.andExpect(status().isOk());
}
}Method Security Testing
@SpringBootTest
class MethodSecurityTest {
@Autowired
private OrderService orderService;
@Test
@WithMockUser(username = "customer-123")
void ownerCanAccessOwnOrder() {
Order order = orderService.findByIdAndCustomer(1L, "customer-123");
assertThat(order).isNotNull();
}
@Test
@WithMockUser(username = "other-customer")
void nonOwnerCannotAccessOrder() {
assertThatThrownBy(() -> orderService.findByIdAndCustomer(1L, "customer-123"))
.isInstanceOf(AccessDeniedException.class);
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanAccessAnyOrder() {
Order order = orderService.findByIdAndCustomer(1L, "customer-123");
assertThat(order).isNotNull();
}
}CSRF Testing
@WebMvcTest(OrderController.class)
class CsrfTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser
void shouldRequireCsrfForPost() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden()); // Missing CSRF token
}
@Test
@WithMockUser
void shouldAcceptWithCsrf() throws Exception {
mockMvc.perform(post("/api/orders")
.with(csrf()) // Add CSRF token
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isCreated());
}
@Test
@WithMockUser
void shouldAcceptWithCsrfAsHeader() throws Exception {
mockMvc.perform(post("/api/orders")
.with(csrf().asHeader()) // X-CSRF-TOKEN header
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isCreated());
}
}Integration Test with Full Security
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class SecurityIntegrationTest {
@Autowired
private WebTestClient webClient;
@Test
void shouldRejectUnauthenticatedRequest() {
webClient.get()
.uri("/api/orders")
.exchange()
.expectStatus().isUnauthorized();
}
@Test
void shouldAcceptBearerToken() {
String token = generateTestJwt();
webClient.get()
.uri("/api/orders")
.headers(headers -> headers.setBearerAuth(token))
.exchange()
.expectStatus().isOk();
}
@Test
void shouldRejectInvalidToken() {
webClient.get()
.uri("/api/orders")
.headers(headers -> headers.setBearerAuth("invalid-token"))
.exchange()
.expectStatus().isUnauthorized();
}
private String generateTestJwt() {
// Generate valid JWT for testing
return Jwts.builder()
.subject("test-user")
.claim("scope", "read write")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 3600000))
.signWith(testKey)
.compact();
}
}Disable Security for Specific Tests
@WebMvcTest(PublicController.class)
@AutoConfigureMockMvc(addFilters = false) // Disable security filters
class PublicControllerTest {
@Autowired
private MockMvcTester mvc;
@Test
void shouldAccessWithoutAuth() {
assertThat(mvc.get().uri("/public/health"))
.hasStatusOk();
}
}Or import a test security config:
@TestConfiguration
public class NoSecurityTestConfig {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.csrf(csrf -> csrf.disable());
return http.build();
}
}Slice Tests
Focused testing with minimal application context.
Table of Contents
- @WebMvcTest
- Java
- Kotlin
- Classic MockMvc (still supported)
- @DataJpaTest
- Java
- Kotlin
- Using Real Database with Testcontainers
- @JsonTest
- @RestClientTest
- @WebFluxTest
- Custom Slice Annotation
- Test Configuration
@WebMvcTest
Tests Spring MVC controllers without starting full server.
Java
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvcTester mvc; // New AssertJ-style API in Boot 4
@MockitoBean
private ProductService productService;
@MockitoBean
private ProductMapper productMapper;
@Test
void shouldReturnProductById() {
Product product = new Product(1L, "Laptop", BigDecimal.valueOf(999.99));
given(productService.findById(1L)).willReturn(Optional.of(product));
given(productMapper.toDto(product)).willReturn(new ProductDto(1L, "Laptop", "999.99"));
assertThat(mvc.get().uri("/api/products/1"))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson()
.extractingPath("$.name").isEqualTo("Laptop");
}
@Test
void shouldReturn404WhenProductNotFound() {
given(productService.findById(999L)).willReturn(Optional.empty());
assertThat(mvc.get().uri("/api/products/999"))
.hasStatus(HttpStatus.NOT_FOUND);
}
@Test
void shouldValidateCreateRequest() {
String invalidRequest = """
{"name": "", "price": -10}
""";
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequest))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson()
.extractingPath("$.errors").isNotEmpty();
}
@Test
void shouldCreateProduct() {
String request = """
{"name": "Tablet", "price": 499.99}
""";
Product saved = new Product(2L, "Tablet", BigDecimal.valueOf(499.99));
given(productService.create(any())).willReturn(saved);
given(productMapper.toDto(saved)).willReturn(new ProductDto(2L, "Tablet", "499.99"));
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "/api/products/2")
.bodyJson()
.extractingPath("$.id").isEqualTo(2);
}
}Kotlin
@WebMvcTest(ProductController::class)
class ProductControllerTest(@Autowired val mvc: MockMvcTester) {
@MockitoBean
lateinit var productService: ProductService
@Test
fun `should return product by id`() {
given(productService.findById(1L))
.willReturn(Optional.of(Product(1L, "Laptop", BigDecimal("999.99"))))
assertThat(mvc.get().uri("/api/products/1"))
.hasStatusOk()
.bodyJson()
.extractingPath("$.name").isEqualTo("Laptop")
}
@Test
fun `should validate create request`() {
val invalidRequest = """{"name": "", "price": -10}"""
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequest))
.hasStatus(HttpStatus.BAD_REQUEST)
}
}Classic MockMvc (still supported)
@WebMvcTest(ProductController.class)
@AutoConfigureMockMvc
class ProductControllerClassicTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private ProductService productService;
@Test
void shouldReturnProduct() throws Exception {
given(productService.findById(1L))
.willReturn(Optional.of(new Product(1L, "Laptop")));
mockMvc.perform(get("/api/products/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.name").value("Laptop"));
}
}@DataJpaTest
Tests JPA repositories with embedded database.
Java
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldSaveOrderWithItems() {
Order order = new Order(CustomerId.generate(), LocalDateTime.now());
order.addItem(new OrderItem("Widget", 2, BigDecimal.TEN));
order.addItem(new OrderItem("Gadget", 1, BigDecimal.valueOf(25)));
Order saved = orderRepository.save(order);
entityManager.flush();
entityManager.clear(); // Critical: forces re-fetch from DB
Order found = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(2);
assertThat(found.getTotal()).isEqualByComparingTo(BigDecimal.valueOf(45));
}
@Test
void shouldFindByCustomerId() {
CustomerId customerId = CustomerId.generate();
Order order1 = entityManager.persist(new Order(customerId, LocalDateTime.now()));
Order order2 = entityManager.persist(new Order(customerId, LocalDateTime.now()));
entityManager.persist(new Order(CustomerId.generate(), LocalDateTime.now())); // Different customer
entityManager.flush();
List<Order> orders = orderRepository.findByCustomerId(customerId);
assertThat(orders).hasSize(2)
.extracting(Order::getId)
.containsExactlyInAnyOrder(order1.getId(), order2.getId());
}
@Test
void shouldCascadeDeleteItems() {
Order order = entityManager.persistAndFlush(new Order(CustomerId.generate()));
order.addItem(new OrderItem("Widget", 1, BigDecimal.TEN));
entityManager.persistAndFlush(order);
Long orderId = order.getId();
orderRepository.deleteById(orderId);
entityManager.flush();
entityManager.clear();
assertThat(orderRepository.findById(orderId)).isEmpty();
// Items also deleted via cascade
}
@Test
void shouldUseEntityGraph() {
Order order = new Order(CustomerId.generate());
order.addItem(new OrderItem("Widget", 2, BigDecimal.TEN));
entityManager.persistAndFlush(order);
entityManager.clear();
// This should NOT cause N+1
Order found = orderRepository.findWithItemsById(order.getId()).orElseThrow();
// Items already loaded (no lazy fetch)
assertThat(Hibernate.isInitialized(found.getItems())).isTrue();
}
}Kotlin
@DataJpaTest
class OrderRepositoryTest(
@Autowired val entityManager: TestEntityManager,
@Autowired val orderRepository: OrderRepository
) {
@Test
fun `should save order with items`() {
val order = Order(CustomerId.generate()).apply {
addItem(OrderItem("Widget", 2, BigDecimal.TEN))
}
orderRepository.save(order)
entityManager.flush()
entityManager.clear()
val found = orderRepository.findById(order.id!!).orElseThrow()
assertThat(found.items).hasSize(1)
}
}Using Real Database with Testcontainers
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPostgresTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldWorkWithPostgres() {
// Test with real Postgres features
}
}@JsonTest
Tests JSON serialization/deserialization.
@JsonTest
class ProductDtoJsonTest {
@Autowired
private JacksonTester<ProductDto> json;
@Test
void shouldSerialize() throws Exception {
ProductDto dto = new ProductDto(1L, "Laptop", BigDecimal.valueOf(999.99));
assertThat(json.write(dto))
.hasJsonPathNumberValue("$.id", 1)
.hasJsonPathStringValue("$.name", "Laptop")
.hasJsonPathNumberValue("$.price", 999.99)
.doesNotHaveJsonPath("$.internalCode");
}
@Test
void shouldDeserialize() throws Exception {
String content = """
{"id": 1, "name": "Laptop", "price": 999.99}
""";
assertThat(json.parse(content))
.usingRecursiveComparison()
.isEqualTo(new ProductDto(1L, "Laptop", BigDecimal.valueOf(999.99)));
}
@Test
void shouldHandleNullFields() throws Exception {
ProductDto dto = new ProductDto(1L, "Laptop", null);
assertThat(json.write(dto))
.doesNotHaveJsonPath("$.price"); // Null excluded
}
}@RestClientTest
Tests REST clients.
@RestClientTest(ExternalApiClient.class)
class ExternalApiClientTest {
@Autowired
private ExternalApiClient client;
@Autowired
private MockRestServiceServer server;
@Test
void shouldFetchData() {
server.expect(requestTo("/api/data/123"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"id": "123", "value": "test"}
""", MediaType.APPLICATION_JSON));
ExternalData data = client.fetchData("123");
assertThat(data.id()).isEqualTo("123");
assertThat(data.value()).isEqualTo("test");
server.verify();
}
@Test
void shouldHandleError() {
server.expect(requestTo("/api/data/999"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> client.fetchData("999"))
.isInstanceOf(ResourceNotFoundException.class);
}
}@WebFluxTest
Tests reactive controllers.
@WebFluxTest(ReactiveProductController.class)
class ReactiveProductControllerTest {
@Autowired
private WebTestClient webClient;
@MockitoBean
private ReactiveProductService productService;
@Test
void shouldReturnProduct() {
given(productService.findById("1"))
.willReturn(Mono.just(new Product("1", "Laptop")));
webClient.get()
.uri("/api/products/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.name").isEqualTo("Laptop");
}
@Test
void shouldStreamProducts() {
given(productService.findAll())
.willReturn(Flux.just(
new Product("1", "Laptop"),
new Product("2", "Tablet")
));
webClient.get()
.uri("/api/products")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.expectBodyList(Product.class)
.hasSize(2);
}
}Custom Slice Annotation
Create reusable test slice:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Testcontainers
@Import(TestConfig.class)
public @interface PostgresRepositoryTest {
}
// Usage
@PostgresRepositoryTest
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
// Tests...
}Test Configuration
@TestConfiguration
public class TestConfig {
@Bean
public Clock testClock() {
return Clock.fixed(Instant.parse("2025-01-15T10:00:00Z"), ZoneOffset.UTC);
}
@Bean
public TestDataFactory testDataFactory() {
return new TestDataFactory();
}
}
// Import in tests
@DataJpaTest
@Import(TestConfig.class)
class OrderRepositoryTest {
@Autowired
private TestDataFactory testData;
}Testcontainers Integration
@ServiceConnection, container patterns, and lifecycle management.
Table of Contents
- @ServiceConnection (Spring Boot 4)
- Basic Usage
- Kotlin
- Supported Containers
- Multiple Containers
- Container Reuse (Faster Tests)
- Singleton Pattern
- Enable Reuse Globally
- Database Initialization
- With Init Script
- With Flyway (Auto-detected)
- Custom Connection Details
- Docker Compose Support
- Waiting Strategies
- Test Data Management
- Per-Test Cleanup
- Transaction Rollback
- Network Configuration
- Resource Cleanup
- CI/CD Configuration
- GitHub Actions
- GitLab CI
@ServiceConnection (Spring Boot 4)
Automatically configures Spring Boot connection properties from containers.
Basic Usage
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private WebTestClient webClient;
@Test
void shouldCreateOrder() {
webClient.post()
.uri("/api/orders")
.bodyValue(new CreateOrderRequest("Widget", 5))
.exchange()
.expectStatus().isCreated();
}
}No need for @DynamicPropertySource — @ServiceConnection handles:
spring.datasource.urlspring.datasource.usernamespring.datasource.password
Kotlin
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest(@Autowired val webClient: WebTestClient) {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:16-alpine")
}
@Test
fun `should create order`() {
webClient.post()
.uri("/api/orders")
.bodyValue(CreateOrderRequest("Widget", 5))
.exchange()
.expectStatus().isCreated
}
}Supported Containers
| Container | Auto-configured Properties |
|---|---|
PostgreSQLContainer | datasource.url, username, password |
MySQLContainer | datasource.url, username, password |
MongoDBContainer | data.mongodb.uri |
RedisContainer | data.redis.host, port |
KafkaContainer | kafka.bootstrap-servers |
RabbitMQContainer | rabbitmq.host, port, username, password |
ElasticsearchContainer | elasticsearch.uris |
CassandraContainer | cassandra.contact-points |
Multiple Containers
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class FullStackIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer("redis:7-alpine");
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@Autowired
private WebTestClient webClient;
@Autowired
private OrderRepository orderRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Test
void shouldProcessOrderEndToEnd() {
// Test with all services
}
}Container Reuse (Faster Tests)
Singleton Pattern
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> postgres;
static final RedisContainer redis;
static {
postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // Reuse between test runs
postgres.start();
redis = new RedisContainer("redis:7-alpine")
.withReuse(true);
redis.start();
}
@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);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
}
@SpringBootTest
class OrderIntegrationTest extends AbstractIntegrationTest {
// Tests reuse containers
}
@SpringBootTest
class PaymentIntegrationTest extends AbstractIntegrationTest {
// Same containers, faster startup
}Enable Reuse Globally
# ~/.testcontainers.properties
testcontainers.reuse.enable=trueDatabase Initialization
With Init Script
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withInitScript("db/init.sql");With Flyway (Auto-detected)
@SpringBootTest
@Testcontainers
class MigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
// Flyway migrations run automatically if spring-boot-starter-flyway is present
@Test
void shouldApplyMigrations(@Autowired Flyway flyway) {
assertThat(flyway.info().applied().length).isGreaterThan(0);
}
}Custom Connection Details
For containers without built-in @ServiceConnection support:
@Container
static GenericContainer<?> customService = new GenericContainer<>("custom:latest")
.withExposedPorts(8080);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("custom.service.url", () ->
"http://" + customService.getHost() + ":" + customService.getMappedPort(8080));
}Docker Compose Support
@SpringBootTest
@Testcontainers
class DockerComposeTest {
@Container
static DockerComposeContainer<?> environment =
new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
.withExposedService("postgres", 5432)
.withExposedService("redis", 6379)
.waitingFor("postgres", Wait.forHealthcheck());
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", () ->
"jdbc:postgresql://" +
environment.getServiceHost("postgres", 5432) + ":" +
environment.getServicePort("postgres", 5432) + "/testdb");
}
}Waiting Strategies
// Wait for log message
@Container
static GenericContainer<?> container = new GenericContainer<>("custom:latest")
.waitingFor(Wait.forLogMessage(".*Started.*\\n", 1));
// Wait for HTTP endpoint
@Container
static GenericContainer<?> container = new GenericContainer<>("custom:latest")
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
// Wait for port
@Container
static GenericContainer<?> container = new GenericContainer<>("custom:latest")
.waitingFor(Wait.forListeningPort());
// Combined wait
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.waitingFor(Wait.forHealthcheck()
.withStartupTimeout(Duration.ofMinutes(2)));Test Data Management
Per-Test Cleanup
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDatabase() {
jdbcTemplate.execute("TRUNCATE TABLE orders CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE customers CASCADE");
}
@Test
void test1() {
// Fresh database
}
@Test
void test2() {
// Fresh database
}
}Transaction Rollback
@SpringBootTest
@Transactional // Rollback after each test
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
Order order = orderService.create(request);
assertThat(order.getId()).isNotNull();
// Rolled back after test
}
}Network Configuration
// Containers on same network
static Network network = Network.newNetwork();
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withNetwork(network)
.withNetworkAliases("postgres");
@Container
static GenericContainer<?> app = new GenericContainer<>("myapp:latest")
.withNetwork(network)
.withEnv("DATABASE_URL", "jdbc:postgresql://postgres:5432/testdb")
.dependsOn(postgres);Resource Cleanup
@SpringBootTest
@Testcontainers
class CleanupTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@AfterAll
static void cleanup() {
// Containers stop automatically with @Container
// But for manual cleanup:
if (postgres.isRunning()) {
postgres.stop();
}
}
}CI/CD Configuration
GitHub Actions
jobs:
test:
runs-on: ubuntu-latest
services:
# No services needed - Testcontainers handles it
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Run tests
run: ./mvnw test
env:
TESTCONTAINERS_RYUK_DISABLED: falseGitLab CI
test:
image: maven:3.9-eclipse-temurin-21
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
TESTCONTAINERS_HOST_OVERRIDE: docker
script:
- mvn testSpring Boot Testing Troubleshooting
Common issues and solutions for Spring Boot 4 testing.
Common Issues
Issue: @MockBean Not Found or Deprecated
Symptom: Compilation error @MockBean cannot be resolved or deprecation warning
Cause: Spring Boot 4 replaced @MockBean with @MockitoBean
Solution:
// Before (Boot 3.x)
@MockBean
private OrderService orderService;
// After (Boot 4.x)
@MockitoBean
private OrderService orderService;Also update @SpyBean to @MockitoSpyBean.
---
Issue: MockMvc Assertions Not Working
Symptom: Cannot use fluent assertions with MockMvc
Cause: Boot 4 introduced MockMvcTester for AssertJ-style assertions
Solution:
// Before (Boot 3.x)
@Autowired
private MockMvc mockMvc;
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("SUBMITTED"));
// After (Boot 4.x) - use MockMvcTester
@Autowired
private MockMvcTester mvc;
assertThat(mvc.get().uri("/api/orders/1"))
.hasStatusOk()
.bodyJson()
.extractingPath("$.status").isEqualTo("SUBMITTED");---
Issue: Testcontainers @ServiceConnection Not Auto-Configuring
Symptom: Tests fail with connection errors despite @ServiceConnection annotation
Cause: Missing Testcontainers dependency or wrong container type
Solution:
1. Ensure correct dependencies:
<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>2. Use correct container class:
// Correct
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
// Wrong - generic container won't auto-configure
@Container
@ServiceConnection
static GenericContainer<?> postgres = new GenericContainer<>("postgres:16");---
Issue: @DataJpaTest Transaction Rollback Hiding Bugs
Symptom: Tests pass but production code fails with lazy loading exceptions
Cause: Test transaction keeps entity manager open, masking LazyInitializationException
Solution:
@Test
void shouldFindOrdersWithItems() {
Order order = new Order();
order.addItem(new OrderItem("Widget", 2));
entityManager.persistAndFlush(order);
entityManager.clear(); // <-- Force detachment to simulate real scenario
Order found = orderRepository.findById(order.getId()).orElseThrow();
// Now lazy loading will fail if not properly configured
assertThat(found.getItems()).hasSize(1);
}---
Issue: @SpringBootTest Too Slow
Symptom: Integration tests take too long to start
Cause: Loading full application context when a slice would suffice
Solution: Use the appropriate slice annotation:
| Need | Use | Not |
|---|---|---|
| Controller test | @WebMvcTest | @SpringBootTest |
| Repository test | @DataJpaTest | @SpringBootTest |
| JSON test | @JsonTest | @SpringBootTest |
| REST client | @RestClientTest | @SpringBootTest |
Reserve @SpringBootTest for true end-to-end integration tests.
---
Issue: @WithMockUser Not Working
Symptom: Security test still returns 401/403 despite @WithMockUser
Cause: Security configuration not loaded or wrong security context
Solution:
1. Ensure @WebMvcTest includes security configuration:
@WebMvcTest(controllers = AdminController.class)
@Import(SecurityConfig.class) // Add if using custom config
class AdminControllerSecurityTest {2. Check role prefix:
// If using hasRole("ADMIN"), the user needs ROLE_ADMIN
@WithMockUser(roles = "ADMIN") // Correct - adds ROLE_ prefix
// If using hasAuthority("ADMIN"), use authorities
@WithMockUser(authorities = "ADMIN") // Direct authority---
Issue: Modulith @ApplicationModuleTest Fails to Load
Symptom: No module found for package error
Cause: Module structure not following conventions or missing package-info.java
Solution:
1. Verify module structure:
com.example.order/
├── package-info.java // Required for module definition
├── Order.java
├── OrderService.java
└── internal/ // Internal package
└── OrderProcessor.java2. Add package-info.java:
@org.springframework.modulith.ApplicationModule(
allowedDependencies = {"shared"}
)
package com.example.order;---
Spring Boot 4 Migration Issues
MockMvc to MockMvcTester Migration
// Step 1: Change injection
// Before
@Autowired MockMvc mockMvc;
// After
@Autowired MockMvcTester mvc;
// Step 2: Update test methods
// Before
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
// After
assertThat(mvc.get().uri("/api/orders"))
.hasStatusOk()
.bodyJson()
.extractingPath("$.length()").isEqualTo(3);@DynamicPropertySource to @ServiceConnection
// Before (Boot 3.x)
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
// After (Boot 4.x)
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
// That's it - no @DynamicPropertySource needed!Imports for New Annotations
// Boot 4 imports
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;