feat: add spring-boot-testing skill for Spring Boot 4.0 (#1085)

- Introduced MockMvcTester for AssertJ-style assertions in Spring MVC testing.
- Added @RestClientTest for testing REST clients with MockRestServiceServer.
- Implemented RestTestClient as a modern alternative to TestRestTemplate.
- Documented migration steps from Spring Boot 3.x to 4.0, including dependency and annotation changes.
- Created an overview of test slices to guide testing strategies.
- Included Testcontainers setup for JDBC testing with PostgreSQL and MySQL.
- Enhanced @WebMvcTest documentation with examples for various HTTP methods and validation.
This commit is contained in:
Kartik Dhiman
2026-03-20 04:54:37 +05:30
committed by GitHub
parent ee71c0689c
commit e4fc57f204
16 changed files with 3171 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
# AssertJ Basics
Fluent assertions for readable, maintainable tests.
## Basic Assertions
### Object Equality
```java
assertThat(order.getStatus()).isEqualTo("PENDING");
assertThat(order.getId()).isNotEqualTo(0);
assertThat(order).isEqualTo(expectedOrder);
assertThat(order).isNotNull();
assertThat(nullOrder).isNull();
```
### String Assertions
```java
assertThat(order.getDescription())
.isEqualTo("Test Order")
.startsWith("Test")
.endsWith("Order")
.contains("Test")
.hasSize(10)
.matches("[A-Za-z ]+");
```
### Number Assertions
```java
assertThat(order.getAmount())
.isEqualTo(99.99)
.isGreaterThan(50)
.isLessThan(100)
.isBetween(50, 100)
.isPositive()
.isNotZero();
```
### Boolean Assertions
```java
assertThat(order.isActive()).isTrue();
assertThat(order.isDeleted()).isFalse();
```
## Date/Time Assertions
```java
assertThat(order.getCreatedAt())
.isEqualTo(LocalDateTime.of(2024, 1, 15, 10, 30))
.isBefore(LocalDateTime.now())
.isAfter(LocalDateTime.of(2024, 1, 1))
.isCloseTo(LocalDateTime.now(), within(5, ChronoUnit.SECONDS));
```
## Optional Assertions
```java
Optional<Order> maybeOrder = orderService.findById(1L);
assertThat(maybeOrder)
.isPresent()
.hasValueSatisfying(order -> {
assertThat(order.getId()).isEqualTo(1L);
});
assertThat(orderService.findById(999L)).isEmpty();
```
## Exception Assertions
### JUnit 5 Exception Handling
```java
@Test
void shouldThrowException() {
OrderService service = new OrderService();
assertThatThrownBy(() -> service.findById(999L))
.isInstanceOf(OrderNotFoundException.class)
.hasMessage("Order 999 not found")
.hasMessageContaining("999");
}
```
### AssertJ Exception Handling
```java
@Test
void shouldThrowExceptionWithCause() {
assertThatExceptionOfType(OrderProcessingException.class)
.isThrownBy(() -> service.processOrder(invalidOrder))
.withCauseInstanceOf(ValidationException.class);
}
```
## Custom Assertions
Create domain-specific assertions for reusable test code:
```java
public class OrderAssert extends AbstractAssert<OrderAssert, Order> {
public static OrderAssert assertThat(Order actual) {
return new OrderAssert(actual);
}
private OrderAssert(Order actual) {
super(actual, OrderAssert.class);
}
public OrderAssert isPending() {
isNotNull();
if (!"PENDING".equals(actual.getStatus())) {
failWithMessage("Expected order status to be PENDING but was %s", actual.getStatus());
}
return this;
}
public OrderAssert hasTotal(BigDecimal expected) {
isNotNull();
if (!expected.equals(actual.getTotal())) {
failWithMessage("Expected total %s but was %s", expected, actual.getTotal());
}
return this;
}
}
```
Usage:
```java
OrderAssert.assertThat(order)
.isPending()
.hasTotal(new BigDecimal("99.99"));
```
## Soft Assertions
Collect multiple failures before failing:
```java
@Test
void shouldValidateOrder() {
Order order = orderService.findById(1L);
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(order.getId()).isEqualTo(1L);
softly.assertThat(order.getStatus()).isEqualTo("PENDING");
softly.assertThat(order.getItems()).isNotEmpty();
});
}
```
## Satisfies Pattern
```java
assertThat(order)
.satisfies(o -> {
assertThat(o.getId()).isPositive();
assertThat(o.getStatus()).isNotBlank();
assertThat(o.getCreatedAt()).isNotNull();
});
```
## Using with Spring
```java
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
Order order = orderService.create(new OrderRequest("Product", 2));
assertThat(order)
.isNotNull()
.extracting(Order::getId, Order::getStatus)
.containsExactly(1L, "PENDING");
}
}
```
## Static Import
Always use static import for clean assertions:
```java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowable;
```
## Key Benefits
1. **Readable**: Sentence-like structure
2. **Type-safe**: IDE autocomplete works
3. **Rich API**: Many built-in assertions
4. **Extensible**: Custom assertions for your domain
5. **Better Errors**: Clear failure messages

View File

@@ -0,0 +1,183 @@
# AssertJ Collections
AssertJ assertions for collections: `List`, `Set`, `Map`, arrays, and streams.
## When to Use This Reference
- The value under test is a `List`, `Set`, `Map`, array, or `Stream`
- You need to assert on multiple elements, their order, or specific fields within them
- You are using `extracting()`, `filteredOn()`, `containsExactly()`, or similar collection methods
- Asserting a single scalar or single object → use [assertj-basics.md](assertj-basics.md) instead
## Basic Collection Checks
```java
List<Order> orders = orderService.findAll();
assertThat(orders).isNotEmpty();
assertThat(orders).isEmpty();
assertThat(orders).hasSize(3);
assertThat(orders).hasSizeGreaterThan(0);
assertThat(orders).hasSizeLessThanOrEqualTo(10);
```
## Containment Assertions
```java
// Contains (any order, allows extras)
assertThat(orders).contains(order1, order2);
// Contains exactly these elements in this order (no extras)
assertThat(statuses).containsExactly("NEW", "PENDING", "COMPLETED");
// Contains exactly these elements in any order (no extras)
assertThat(statuses).containsExactlyInAnyOrder("COMPLETED", "NEW", "PENDING");
// Contains any of these elements (at least one match required)
assertThat(statuses).containsAnyOf("NEW", "CANCELLED");
// Does not contain
assertThat(statuses).doesNotContain("DELETED");
```
## Extracting Fields
Extract a single field from each element before asserting:
```java
assertThat(orders)
.extracting(Order::getStatus)
.containsExactly("NEW", "PENDING", "COMPLETED");
```
Extract multiple fields as tuples:
```java
assertThat(orders)
.extracting(Order::getId, Order::getStatus)
.containsExactly(
tuple(1L, "NEW"),
tuple(2L, "PENDING"),
tuple(3L, "COMPLETED")
);
```
## Filtering Before Asserting
```java
assertThat(orders)
.filteredOn(order -> order.getStatus().equals("PENDING"))
.hasSize(2)
.extracting(Order::getId)
.containsExactlyInAnyOrder(1L, 3L);
// Filter by field value
assertThat(orders)
.filteredOn("status", "PENDING")
.hasSize(2);
```
## Predicate Checks
```java
assertThat(orders).allMatch(o -> o.getTotal().compareTo(BigDecimal.ZERO) > 0);
assertThat(orders).anyMatch(o -> o.getStatus().equals("COMPLETED"));
assertThat(orders).noneMatch(o -> o.getStatus().equals("DELETED"));
// With description for failure messages
assertThat(orders)
.allSatisfy(o -> assertThat(o.getId()).isPositive());
```
## Per-Element Ordered Assertions
Assert each element in order with individual conditions:
```java
assertThat(orders).satisfiesExactly(
first -> assertThat(first.getStatus()).isEqualTo("NEW"),
second -> assertThat(second.getStatus()).isEqualTo("PENDING"),
third -> {
assertThat(third.getStatus()).isEqualTo("COMPLETED");
assertThat(third.getTotal()).isGreaterThan(BigDecimal.ZERO);
}
);
```
## Nested / Flat Collections
```java
// flatExtracting: flatten one level of nested collections
assertThat(orders)
.flatExtracting(Order::getItems)
.extracting(OrderItem::getProduct)
.contains("Laptop", "Mouse");
```
## Recursive Field Comparison
Compare elements by fields instead of object identity:
```java
assertThat(orders)
.usingRecursiveFieldByFieldElementComparator()
.containsExactlyInAnyOrder(expectedOrder1, expectedOrder2);
// Ignore specific fields (e.g. generated IDs or timestamps)
assertThat(orders)
.usingRecursiveFieldByFieldElementComparatorIgnoringFields("id", "createdAt")
.containsExactly(expectedOrder1, expectedOrder2);
```
## Map Assertions
```java
Map<String, Integer> stockByProduct = inventoryService.getStock();
assertThat(stockByProduct)
.isNotEmpty()
.hasSize(3)
.containsKey("Laptop")
.doesNotContainKey("Fax Machine")
.containsEntry("Laptop", 10)
.containsEntries(entry("Laptop", 10), entry("Mouse", 50));
assertThat(stockByProduct)
.hasEntrySatisfying("Laptop", qty -> assertThat(qty).isGreaterThan(0));
```
## Array Assertions
```java
String[] roles = user.getRoles();
assertThat(roles).hasSize(2);
assertThat(roles).contains("ADMIN");
assertThat(roles).containsExactlyInAnyOrder("USER", "ADMIN");
```
## Set Assertions
```java
Set<String> tags = product.getTags();
assertThat(tags).contains("electronics", "sale");
assertThat(tags).doesNotContain("expired");
assertThat(tags).hasSizeGreaterThanOrEqualTo(1);
```
## Static Import
```java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.assertj.core.api.Assertions.entry;
```
## Key Points
1. **`containsExactly` vs `containsExactlyInAnyOrder`** — use the former when order matters
2. **`extracting()` before containment checks** — avoids implementing `equals()` on domain objects
3. **`filteredOn()` + `extracting()`** — compose to assert a subset of a collection precisely
4. **`satisfiesExactly()`** — use when each element needs different assertions
5. **`usingRecursiveFieldByFieldElementComparator()`** — preferred over `equals()` for DTOs and records

View File

@@ -0,0 +1,115 @@
# Context Caching
Optimize Spring Boot test suite performance through context caching.
## How Context Caching Works
Spring's TestContext Framework caches application contexts based on their configuration "key". Tests with identical configurations reuse the same context.
### What Affects the Cache Key
- @ContextConfiguration
- @TestPropertySource
- @ActiveProfiles
- @WebAppConfiguration
- @MockitoBean definitions
- @TestConfiguration imports
## Cache Key Examples
### Same Key (Context Reused)
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest1 {
@MockitoBean private OrderService orderService;
}
@WebMvcTest(OrderController.class)
class OrderControllerTest2 {
@MockitoBean private OrderService orderService;
}
// Same context reused
```
### Different Key (New Context)
```java
@WebMvcTest(OrderController.class)
@ActiveProfiles("test")
class OrderControllerTest1 { }
@WebMvcTest(OrderController.class)
@ActiveProfiles("integration")
class OrderControllerTest2 { }
// Different contexts loaded
```
## Viewing Cache Statistics
### Spring Boot Actuator
```yaml
management:
endpoints:
web:
exposure:
include: metrics
```
Access: `GET /actuator/metrics/spring.test.context.cache`
### Debug Logging
```properties
logging.level.org.springframework.test.context.cache=DEBUG
```
## Optimizing Cache Hit Rate
### Group Tests by Configuration
```
tests/
unit/ # No context
web/ # @WebMvcTest
repository/ # @DataJpaTest
integration/ # @SpringBootTest
```
### Minimize @TestPropertySource Variations
**Bad (multiple contexts):**
```java
@TestPropertySource(properties = "app.feature-x=true")
class FeatureXTest { }
@TestPropertySource(properties = "app.feature-y=true")
class FeatureYTest { }
```
**Better (grouped):**
```java
@TestPropertySource(properties = {"app.feature-x=true", "app.feature-y=true"})
class FeaturesTest { }
```
### Use @DirtiesContext Sparingly
Only when context state truly changes:
```java
@Test
@DirtiesContext // Forces context rebuild after test
void testThatModifiesBeanDefinitions() { }
```
## Best Practices
1. **Group by configuration** - Keep tests with same config together
2. **Limit property variations** - Use profiles over individual properties
3. **Avoid @DirtiesContext** - Prefer test data cleanup
4. **Use narrow slices** - @WebMvcTest vs @SpringBootTest
5. **Monitor cache hits** - Enable debug logging occasionally

View File

@@ -0,0 +1,197 @@
# @DataJpaTest
Testing JPA repositories with isolated data layer slice.
## Basic Structure
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
}
```
## What Gets Loaded
- Repository beans
- EntityManager / TestEntityManager
- DataSource
- Transaction manager
- No web layer, no services, no controllers
## Testing Custom Queries
```java
@Test
void shouldFindOrdersByStatus() {
// Given - Using var for cleaner code
var pending = new Order("PENDING");
var completed = new Order("COMPLETED");
entityManager.persist(pending);
entityManager.persist(completed);
entityManager.flush();
// When
var pendingOrders = orderRepository.findByStatus("PENDING");
// Then - Using sequenced collection methods
assertThat(pendingOrders).hasSize(1);
assertThat(pendingOrders.getFirst().getStatus()).isEqualTo("PENDING");
}
```
## Testing Native Queries
```java
@Test
void shouldExecuteNativeQuery() {
entityManager.persist(new Order("PENDING", BigDecimal.valueOf(100)));
entityManager.persist(new Order("PENDING", BigDecimal.valueOf(200)));
entityManager.flush();
var total = orderRepository.calculatePendingTotal();
assertThat(total).isEqualTo(new BigDecimal("300.00"));
}
```
## Testing Pagination
```java
@Test
void shouldReturnPagedResults() {
// Insert 20 orders using IntStream
IntStream.range(0, 20).forEach(i -> {
entityManager.persist(new Order("PENDING"));
});
entityManager.flush();
var page = orderRepository.findByStatus("PENDING", PageRequest.of(0, 10));
assertThat(page.getContent()).hasSize(10);
assertThat(page.getTotalElements()).isEqualTo(20);
assertThat(page.getContent().getFirst().getStatus()).isEqualTo("PENDING");
}
```
## Testing Lazy Loading
```java
@Test
void shouldLazyLoadOrderItems() {
var order = new Order("PENDING");
order.addItem(new OrderItem("Product", 2));
entityManager.persist(order);
entityManager.flush();
entityManager.clear(); // Detach from persistence context
var found = orderRepository.findById(order.getId());
assertThat(found).isPresent();
// This will trigger lazy loading
assertThat(found.get().getItems()).hasSize(1);
assertThat(found.get().getItems().getFirst().getProduct()).isEqualTo("Product");
}
```
## Testing Cascading
```java
@Test
void shouldCascadeDelete() {
var order = new Order("PENDING");
order.addItem(new OrderItem("Product", 2));
entityManager.persist(order);
entityManager.flush();
orderRepository.delete(order);
entityManager.flush();
assertThat(entityManager.find(OrderItem.class, order.getItems().getFirst().getId()))
.isNull();
}
```
## Testing @Query Methods
```java
@Query("SELECT o FROM Order o WHERE o.createdAt > :date AND o.status = :status")
List<Order> findRecentByStatus(@Param("date") LocalDateTime date,
@Param("status") String status);
@Test
void shouldFindRecentOrders() {
var old = new Order("PENDING");
old.setCreatedAt(LocalDateTime.now().minusDays(10));
var recent = new Order("PENDING");
recent.setCreatedAt(LocalDateTime.now().minusHours(1));
entityManager.persist(old);
entityManager.persist(recent);
entityManager.flush();
var recentOrders = orderRepository.findRecentByStatus(
LocalDateTime.now().minusDays(1), "PENDING");
assertThat(recentOrders).hasSize(1);
assertThat(recentOrders.getFirst().getId()).isEqualTo(recent.getId());
}
```
## Using H2 vs Real Database
### H2 (Default - Not Recommended for Production Parity)
```java
@DataJpaTest // Uses embedded H2 by default
class OrderRepositoryH2Test {
// Fast but may miss DB-specific issues
}
```
### Testcontainers (Recommended)
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryPostgresTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
}
```
## Transaction Behavior
Tests are @Transactional by default and roll back after each test.
```java
@Test
@Rollback(false) // Don't roll back (rarely needed)
void shouldPersistData() {
orderRepository.save(new Order("PENDING"));
// Data will remain in database after test
}
```
## Key Points
1. Use TestEntityManager for setup data
2. Always flush() after persist() to trigger SQL
3. Clear() the entity manager to test lazy loading
4. Use real database (Testcontainers) for accurate results
5. Test both success and failure cases
6. Leverage Java 25 var keyword for cleaner variable declarations
7. Use sequenced collection methods (getFirst(), getLast(), reversed())

View File

@@ -0,0 +1,230 @@
# Instancio
Generate complex test objects automatically. Use when entities/DTOs have 3+ properties.
## When to Use
- Objects with **3 or more properties**
- Setting up test data for repositories
- Creating DTOs for controller tests
- Avoiding repetitive builder/setter calls
## Dependency
```xml
<dependency>
<groupId>org.instancio</groupId>
<artifactId>instancio-junit</artifactId>
<version>5.5.1</version>
<scope>test</scope>
</dependency>
```
## Basic Usage
### Simple Object
```java
final var order = Instancio.create(Order.class);
// All fields populated with random data
```
### List of Objects
```java
final var orders = Instancio.ofList(Order.class).size(5).create();
// 5 orders with random data
```
## Customizing Values
### Set Specific Fields
```java
final var order = Instancio.of(Order.class)
.set(field(Order::getStatus), "PENDING")
.set(field(Order::getTotal), new BigDecimal("99.99"))
.create();
```
### Supply Generated Values
```java
final var order = Instancio.of(Order.class)
.supply(field(Order::getEmail), () -> "user" + UUID.randomUUID() + "@test.com")
.create();
```
### Ignore Fields
```java
final var order = Instancio.of(Order.class)
.ignore(field(Order::getId)) // Let DB generate
.create();
```
## Complex Objects
### Nested Objects
```java
final var order = Instancio.of(Order.class)
.set(field(Order::getCustomer), Instancio.create(Customer.class))
.set(field(Order::getItems), Instancio.ofList(OrderItem.class).size(3).create())
.create();
```
### All Fields Random
```java
// When you need fully random but valid data
final var randomOrder = Instancio.create(Order.class);
// Customer, items, addresses - all populated
```
## Spring Boot Integration
### Repository Test Setup
```java
@DataJpaTest
@AutoConfigureTestDatabase
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrdersByStatus() {
// Given: Create 10 random orders with PENDING status
final var orders = Instancio.ofList(Order.class)
.size(10)
.set(field(Order::getStatus), "PENDING")
.create();
orderRepository.saveAll(orders);
// When
final var found = orderRepository.findByStatus("PENDING");
// Then
assertThat(found).hasSize(10);
}
}
```
### Controller Test Setup
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldReturnOrder() {
// Given: Random order with specific ID
Order order = Instancio.of(Order.class)
.set(field(Order::getId), 1L)
.create();
given(orderService.findById(1L)).willReturn(order);
// When/Then
assertThat(mvc.get().uri("/orders/1"))
.hasStatus(HttpStatus.OK)
.bodyJson()
.convertTo(OrderResponse.class)
.satisfies(response -> {
assertThat(response.getId()).isEqualTo(1L);
});
}
}
```
## Patterns
### Builder Pattern Alternative
```java
// Instead of:
Order order = Order.builder()
.id(1L)
.status("PENDING")
.customer(Customer.builder().name("John").build())
.items(List.of(
OrderItem.builder().product("A").price(10).build(),
OrderItem.builder().product("B").price(20).build()
))
.build();
// Use:
Order order = Instancio.of(Order.class)
.set(field(Order::getId), 1L)
.set(field(Order::getStatus), "PENDING")
.create();
// Customer and items auto-generated
```
### Seeded Data
```java
// Consistent "random" data for reproducible tests
Order order = Instancio.of(Order.class)
.withSeed(12345L)
.create();
// Same data every test run with seed 12345
```
## Common Patterns
### Email Generation
```java
String email = Instancio.gen().net().email();
```
### Date Generation
```java
LocalDateTime createdAt = Instancio.gen().temporal()
.localDateTime()
.past()
.create();
```
### String Patterns
```java
String phone = Instancio.gen().text().pattern("+1-###-###-####");
```
## Comparison
| Approach | Lines of Code | Maintainability |
| -------- | ------------- | --------------- |
| Manual setters | 10-20 | Low |
| Builder pattern | 5-10 | Medium |
| **Instancio** | 2-5 | **High** |
## Best Practices
1. **Use for 3+ property objects** - Not worth it for simple objects
2. **Set only what's relevant** - Let Instancio fill the rest
3. **Use with Testcontainers** - Great for database seeding
4. **Set IDs explicitly** - When testing specific scenarios
5. **Ignore auto-generated fields** - Like createdAt, updatedAt
## Links
- [Instancio Documentation](https://www.instancio.org/)
- [JUnit 5 Extension](https://www.instancio.org/user-guide/#junit-integration)

View File

@@ -0,0 +1,232 @@
# @MockitoBean
Mocking dependencies in Spring Boot tests (replaces deprecated @MockBean in Spring Boot 4+).
## Overview
`@MockitoBean` replaces the deprecated `@MockBean` annotation in Spring Boot 4.0+. It creates a Mockito mock and registers it in the Spring context, replacing any existing bean of the same type.
## Basic Usage
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
}
```
## Supported Test Slices
- `@WebMvcTest` - Mock service/repository dependencies
- `@WebFluxTest` - Mock reactive service dependencies
- `@SpringBootTest` - Replace real beans with mocks
## Stubbing Methods
### Basic Stub
```java
@Test
void shouldReturnOrder() {
Order order = new Order(1L, "PENDING");
given(orderService.findById(1L)).willReturn(order);
// Test code
}
```
### Multiple Returns
```java
given(orderService.findById(anyLong()))
.willReturn(new Order(1L, "PENDING"))
.willReturn(new Order(2L, "COMPLETED"));
```
### Throwing Exceptions
```java
given(orderService.findById(999L))
.willThrow(new OrderNotFoundException(999L));
```
### Argument Matching
```java
given(orderService.create(argThat(req -> req.getQuantity() > 0)))
.willReturn(1L);
given(orderService.findByStatus(eq("PENDING")))
.willReturn(List.of(new Order()));
```
## Verifying Interactions
### Verify Method Called
```java
verify(orderService).findById(1L);
```
### Verify Never Called
```java
verify(orderService, never()).delete(any());
```
### Verify Count
```java
verify(orderService, times(2)).findById(anyLong());
verify(orderService, atLeastOnce()).findByStatus(anyString());
```
### Verify Order
```java
InOrder inOrder = inOrder(orderService, userService);
inOrder.verify(orderService).findById(1L);
inOrder.verify(userService).getUser(any());
```
## Resetting Mocks
Mocks are reset between tests automatically. To reset mid-test:
```java
Mockito.reset(orderService);
```
## @MockitoSpyBean for Partial Mocking
Use `@MockitoSpyBean` to wrap a real bean with Mockito.
```java
@SpringBootTest
class OrderServiceIntegrationTest {
@MockitoSpyBean
private PaymentGatewayClient paymentClient;
@Test
void shouldProcessOrder() {
doReturn(true).when(paymentClient).processPayment(any());
// Test with real service but mocked payment client
}
}
```
## @TestBean for Custom Test Beans
Register a custom bean instance in the test context:
```java
@SpringBootTest
class OrderServiceTest {
@TestBean
private PaymentGatewayClient paymentClient() {
return new FakePaymentClient();
}
}
```
## Scoping: Singleton vs Prototype
Spring Framework 7+ (Spring Boot 4+) supports mocking non-singleton beans:
```java
@Component
@Scope("prototype")
public class OrderProcessor {
public String process() { return "real"; }
}
@SpringBootTest
class OrderServiceTest {
@MockitoBean
private OrderProcessor orderProcessor;
@Test
void shouldWorkWithPrototype() {
given(orderProcessor.process()).willReturn("mocked");
// Test code
}
}
```
## Common Patterns
### Mocking Repository in Service Test
```java
@SpringBootTest
class OrderServiceTest {
@MockitoBean
private OrderRepository orderRepository;
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
given(orderRepository.save(any())).willReturn(new Order(1L));
Long id = orderService.createOrder(new OrderRequest());
assertThat(id).isEqualTo(1L);
verify(orderRepository).save(any(Order.class));
}
}
```
### Multiple Mocks of Same Type
Use bean names:
```java
@MockitoBean(name = "primaryDataSource")
private DataSource primaryDataSource;
@MockitoBean(name = "secondaryDataSource")
private DataSource secondaryDataSource;
```
## Migration from @MockBean
### Before (Deprecated)
```java
@MockBean
private OrderService orderService;
```
### After (Spring Boot 4+)
```java
@MockitoBean
private OrderService orderService;
```
## Key Differences from Mockito @Mock
| Feature | @MockitoBean | @Mock |
| ------- | ------------ | ----- |
| Context integration | Yes | No |
| Spring lifecycle | Participates | None |
| Works with @Autowired | Yes | No |
| Test slice support | Yes | Limited |
## Best Practices
1. Use `@MockitoBean` only when Spring context is involved
2. For pure unit tests, use Mockito's `@Mock` or `Mockito.mock()`
3. Always verify interactions that have side effects
4. Don't verify simple queries (stubbing is enough)
5. Reset mocks if test modifies shared mock state

View File

@@ -0,0 +1,206 @@
# MockMvc Classic
Traditional MockMvc API for Spring MVC controller tests (pre-Spring Boot 3.2 or legacy codebases).
## When to Use This Reference
- The project uses Spring Boot < 3.2 (no `MockMvcTester` available)
- Existing tests use `mvc.perform(...)` and you are maintaining or extending them
- You need to migrate classic MockMvc tests to `MockMvcTester` (see migration section below)
- The user explicitly asks about `ResultActions`, `andExpect()`, or Hamcrest-style web assertions
For new tests on Spring Boot 3.2+, prefer [mockmvc-tester.md](mockmvc-tester.md) instead.
## Setup
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private OrderService orderService;
}
```
## Basic GET Request
```java
@Test
void shouldReturnOrder() throws Exception {
given(orderService.findById(1L)).willReturn(new Order(1L, "PENDING", 99.99));
mvc.perform(get("/orders/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.totalToPay").value(99.99));
}
```
## POST with Request Body
```java
@Test
void shouldCreateOrder() throws Exception {
given(orderService.create(any(OrderRequest.class))).willReturn(1L);
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"product\": \"Laptop\", \"quantity\": 2}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/orders/1"));
}
```
## PUT Request
```java
@Test
void shouldUpdateOrder() throws Exception {
mvc.perform(put("/orders/1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\": \"COMPLETED\"}"))
.andExpect(status().isOk());
}
```
## DELETE Request
```java
@Test
void shouldDeleteOrder() throws Exception {
mvc.perform(delete("/orders/1"))
.andExpect(status().isNoContent());
}
```
## Status Matchers
```java
.andExpect(status().isOk()) // 200
.andExpect(status().isCreated()) // 201
.andExpect(status().isNoContent()) // 204
.andExpect(status().isBadRequest()) // 400
.andExpect(status().isUnauthorized()) // 401
.andExpect(status().isForbidden()) // 403
.andExpect(status().isNotFound()) // 404
.andExpect(status().is(422)) // arbitrary code
```
## JSON Path Assertions
```java
// Exact value
.andExpect(jsonPath("$.status").value("PENDING"))
// Existence
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.deletedAt").doesNotExist())
// Array size
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items", hasSize(3)))
// Nested field
.andExpect(jsonPath("$.customer.name").value("John Doe"))
.andExpect(jsonPath("$.customer.address.city").value("Berlin"))
// With Hamcrest matchers
.andExpect(jsonPath("$.total", greaterThan(0.0)))
.andExpect(jsonPath("$.description", containsString("order")))
```
## Content Assertions
```java
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(content().string(containsString("PENDING")))
.andExpect(content().json("{\"status\":\"PENDING\"}"))
```
## Header Assertions
```java
.andExpect(header().string("Location", "/orders/1"))
.andExpect(header().string("Content-Type", containsString("application/json")))
.andExpect(header().exists("X-Request-Id"))
.andExpect(header().doesNotExist("X-Deprecated"))
```
## Request Parameters and Headers
```java
// Query parameters
mvc.perform(get("/orders").param("status", "PENDING").param("page", "0"))
.andExpect(status().isOk());
// Path variables
mvc.perform(get("/orders/{id}", 1L))
.andExpect(status().isOk());
// Request headers
mvc.perform(get("/orders/1").header("X-Api-Key", "secret"))
.andExpect(status().isOk());
```
## Capturing the Response
```java
@Test
void shouldReturnCreatedId() throws Exception {
given(orderService.create(any())).willReturn(42L);
MvcResult result = mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"product\": \"Laptop\", \"quantity\": 1}"))
.andExpect(status().isCreated())
.andReturn();
String location = result.getResponse().getHeader("Location");
assertThat(location).isEqualTo("/orders/42");
}
```
## Chaining with andDo
```java
mvc.perform(get("/orders/1"))
.andDo(print()) // prints request/response to console (debug)
.andExpect(status().isOk());
```
## Static Imports
```java
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.hamcrest.Matchers.*;
```
## Migration to MockMvcTester
| Classic MockMvc | MockMvcTester (recommended) |
| --- | --- |
| `@Autowired MockMvc mvc` | `@Autowired MockMvcTester mvc` |
| `mvc.perform(get("/orders/1"))` | `mvc.get().uri("/orders/1")` |
| `.andExpect(status().isOk())` | `.hasStatusOk()` |
| `.andExpect(jsonPath("$.status").value("X"))` | `.bodyJson().convertTo(T.class)` + AssertJ |
| `throws Exception` on every method | No checked exception |
| Hamcrest matchers | AssertJ fluent assertions |
See [mockmvc-tester.md](mockmvc-tester.md) for the full modern API.
## Key Points
1. **Every test method must declare `throws Exception`**`perform()` throws checked exceptions
2. **Use `andDo(print())` during debugging** — remove before committing
3. **Prefer `jsonPath()` over `content().string()`** — more precise field-level assertions
4. **Static imports are required** — IDE can auto-add them
5. **Migrate to MockMvcTester** when upgrading to Spring Boot 3.2+ for better readability

View File

@@ -0,0 +1,311 @@
# MockMvcTester
AssertJ-style testing for Spring MVC controllers (Spring Boot 3.2+).
## Overview
MockMvcTester provides fluent, AssertJ-style assertions for web layer testing. More readable and type-safe than traditional MockMvc.
**Recommended Pattern**: Convert JSON to real objects and assert with AssertJ:
```java
assertThat(mvc.get().uri("/orders/1"))
.hasStatus(HttpStatus.OK)
.bodyJson()
.convertTo(OrderResponse.class)
.satisfies(response -> {
assertThat(response.getTotalToPay()).isEqualTo(expectedAmount);
assertThat(response.getItems()).isNotEmpty();
});
```
## Basic Usage
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private OrderService orderService;
}
```
## Recommended: Object Conversion Pattern
### Single Object Response
```java
@Test
void shouldGetOrder() {
given(orderService.findById(1L)).willReturn(new Order(1L, "PENDING", 99.99));
assertThat(mvc.get().uri("/orders/1"))
.hasStatus(HttpStatus.OK)
.bodyJson()
.convertTo(OrderResponse.class)
.satisfies(response -> {
assertThat(response.getId()).isEqualTo(1L);
assertThat(response.getStatus()).isEqualTo("PENDING");
assertThat(response.getTotalToPay()).isEqualTo(new BigDecimal("99.99"));
});
}
```
### List Response
```java
@Test
void shouldGetAllOrders() {
given(orderService.findAll()).willReturn(Arrays.asList(
new Order(1L, "PENDING"),
new Order(2L, "COMPLETED")
));
assertThat(mvc.get().uri("/orders"))
.hasStatus(HttpStatus.OK)
.bodyJson()
.convertTo(new TypeReference<List<OrderResponse>>() {})
.satisfies(orders -> {
assertThat(orders).hasSize(2);
assertThat(orders.get(0).getStatus()).isEqualTo("PENDING");
assertThat(orders.get(1).getStatus()).isEqualTo("COMPLETED");
});
}
```
### Nested Objects
```java
@Test
void shouldGetOrderWithCustomer() {
assertThat(mvc.get().uri("/orders/1"))
.hasStatus(HttpStatus.OK)
.bodyJson()
.convertTo(OrderResponse.class)
.satisfies(response -> {
assertThat(response.getCustomer()).isNotNull();
assertThat(response.getCustomer().getName()).isEqualTo("John Doe");
assertThat(response.getCustomer().getAddress().getCity()).isEqualTo("Berlin");
});
}
```
### Complex Assertions
```java
@Test
void shouldCalculateOrderTotal() {
assertThat(mvc.get().uri("/orders/1/calculate"))
.hasStatus(HttpStatus.OK)
.bodyJson()
.convertTo(CalculationResponse.class)
.satisfies(calc -> {
assertThat(calc.getSubtotal()).isEqualTo(new BigDecimal("100.00"));
assertThat(calc.getTax()).isEqualTo(new BigDecimal("19.00"));
assertThat(calc.getTotalToPay()).isEqualTo(new BigDecimal("119.00"));
assertThat(calc.getItems()).allMatch(item -> item.getPrice().compareTo(BigDecimal.ZERO) > 0);
});
}
```
## HTTP Methods
### POST with Request Body
```java
@Test
void shouldCreateOrder() {
given(orderService.create(any())).willReturn(1L);
assertThat(mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"product\": \"Laptop\", \"quantity\": 2}"))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "/orders/1");
}
```
### PUT Request
```java
@Test
void shouldUpdateOrder() {
assertThat(mvc.put().uri("/orders/1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\": \"COMPLETED\"}"))
.hasStatus(HttpStatus.OK);
}
```
### DELETE Request
```java
@Test
void shouldDeleteOrder() {
assertThat(mvc.delete().uri("/orders/1"))
.hasStatus(HttpStatus.NO_CONTENT);
}
```
## Status Assertions
```java
assertThat(mvc.get().uri("/orders/1"))
.hasStatusOk() // 200
.hasStatus(HttpStatus.OK) // 200
.hasStatus2xxSuccessful() // 2xx
.hasStatusBadRequest() // 400
.hasStatusNotFound() // 404
.hasStatusUnauthorized() // 401
.hasStatusForbidden() // 403
.hasStatus(HttpStatus.CREATED); // 201
```
## Content Type Assertions
```java
assertThat(mvc.get().uri("/orders/1"))
.hasContentType(MediaType.APPLICATION_JSON)
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON);
```
## Header Assertions
```java
assertThat(mvc.post().uri("/orders"))
.hasHeader("Location", "/orders/123")
.hasHeader("X-Request-Id", matchesPattern("[a-z0-9-]+"));
```
## Alternative: JSON Path (Use Sparingly)
Only use when you cannot convert to a typed object:
```java
assertThat(mvc.get().uri("/orders/1"))
.hasStatusOk()
.bodyJson()
.extractingPath("$.customer.address.city")
.asString()
.isEqualTo("Berlin");
```
## Request Parameters
```java
// Query parameters
assertThat(mvc.get().uri("/orders?status=PENDING&page=0"))
.hasStatusOk();
// Path parameters
assertThat(mvc.get().uri("/orders/{id}", 1L))
.hasStatusOk();
// Headers
assertThat(mvc.get().uri("/orders/1")
.header("X-Api-Key", "secret"))
.hasStatusOk();
```
## Request Body with JacksonTester
```java
@Autowired
private JacksonTester<OrderRequest> json;
@Test
void shouldCreateOrder() {
OrderRequest request = new OrderRequest("Laptop", 2);
assertThat(mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(json.write(request).getJson()))
.hasStatus(HttpStatus.CREATED);
}
```
## Error Responses
```java
@Test
void shouldReturnValidationErrors() {
given(orderService.findById(999L))
.willThrow(new OrderNotFoundException(999L));
assertThat(mvc.get().uri("/orders/999"))
.hasStatus(HttpStatus.NOT_FOUND)
.bodyJson()
.convertTo(ErrorResponse.class)
.satisfies(error -> {
assertThat(error.getMessage()).isEqualTo("Order 999 not found");
assertThat(error.getCode()).isEqualTo("ORDER_NOT_FOUND");
});
}
```
## Validation Error Testing
```java
@Test
void shouldRejectInvalidOrder() {
OrderRequest invalidRequest = new OrderRequest("", -1);
assertThat(mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(json.write(invalidRequest).getJson()))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson()
.convertTo(ValidationErrorResponse.class)
.satisfies(errors -> {
assertThat(errors.getFieldErrors()).hasSize(2);
assertThat(errors.getFieldErrors())
.extracting("field")
.contains("product", "quantity");
});
}
```
## Comparison: MockMvcTester vs Classic MockMvc
| Feature | MockMvcTester | Classic MockMvc |
| ------- | ------------- | --------------- |
| Style | AssertJ fluent | MockMvc matchers |
| Readability | High | Medium |
| Type Safety | Better | Less |
| IDE Support | Excellent | Good |
| Object Conversion | Native | Manual |
## Migration from Classic MockMvc
### Before (Classic)
```java
mvc.perform(get("/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.totalToPay").value(99.99));
```
### After (Tester with Object Conversion)
```java
assertThat(mvc.get().uri("/orders/1"))
.hasStatus(HttpStatus.OK)
.bodyJson()
.convertTo(OrderResponse.class)
.satisfies(response -> {
assertThat(response.getStatus()).isEqualTo("PENDING");
assertThat(response.getTotalToPay()).isEqualTo(new BigDecimal("99.99"));
});
```
## Key Points
1. **Prefer `convertTo()` over `extractingPath()`** - Type-safe, refactorable
2. **Use `satisfies()` for multiple assertions** - Keeps tests readable
3. **Import static `org.assertj.core.api.Assertions.assertThat`**
4. **Works with generics via `TypeReference`** - For `List<T>` responses
5. **IDE refactoring friendly** - Rename fields, IDE updates tests

View File

@@ -0,0 +1,227 @@
# @RestClientTest
Testing REST clients in isolation with MockRestServiceServer.
## Overview
`@RestClientTest` auto-configures:
- RestTemplate/RestClient with mock server support
- Jackson ObjectMapper
- MockRestServiceServer
## Basic Setup
```java
@RestClientTest(WeatherService.class)
class WeatherServiceTest {
@Autowired
private WeatherService weatherService;
@Autowired
private MockRestServiceServer server;
}
```
## Testing RestTemplate
```java
@RestClientTest(WeatherService.class)
class WeatherServiceTest {
@Autowired
private WeatherService weatherService;
@Autowired
private MockRestServiceServer server;
@Test
void shouldFetchWeather() {
// Given
server.expect(requestTo("https://api.weather.com/v1/current"))
.andExpect(method(HttpMethod.GET))
.andExpect(queryParam("city", "Berlin"))
.andRespond(withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body("{\"temperature\": 22, \"condition\": \"Sunny\"}"));
// When
Weather weather = weatherService.getCurrentWeather("Berlin");
// Then
assertThat(weather.getTemperature()).isEqualTo(22);
assertThat(weather.getCondition()).isEqualTo("Sunny");
}
}
```
## Testing RestClient (Spring 6.1+)
```java
@RestClientTest(WeatherService.class)
class WeatherServiceTest {
@Autowired
private WeatherService weatherService;
@Autowired
private MockRestServiceServer server;
@Test
void shouldFetchWeatherWithRestClient() {
server.expect(requestTo("https://api.weather.com/v1/current"))
.andRespond(withSuccess()
.body("{\"temperature\": 22}"));
Weather weather = weatherService.getCurrentWeather("Berlin");
assertThat(weather.getTemperature()).isEqualTo(22);
}
}
```
## Request Matching
### Exact URL
```java
server.expect(requestTo("https://api.example.com/users/1"))
.andRespond(withSuccess());
```
### URL Pattern
```java
server.expect(requestTo(matchesPattern("https://api.example.com/users/\\d+")))
.andRespond(withSuccess());
```
### HTTP Method
```java
server.expect(ExpectedCount.once(),
requestTo("https://api.example.com/users"))
.andExpect(method(HttpMethod.POST))
.andRespond(withCreatedEntity(URI.create("/users/1")));
```
### Request Body
```java
server.expect(requestTo("https://api.example.com/users"))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().json("{\"name\": \"John\"}"))
.andRespond(withSuccess());
```
### Headers
```java
server.expect(requestTo("https://api.example.com/users"))
.andExpect(header("Authorization", "Bearer token123"))
.andExpect(header("X-Api-Key", "secret"))
.andRespond(withSuccess());
```
## Response Types
### Success with Body
```java
server.expect(requestTo("/users/1"))
.andRespond(withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body("{\"id\": 1, \"name\": \"John\"}"));
```
### Success from Resource
```java
server.expect(requestTo("/users/1"))
.andRespond(withSuccess()
.body(new ClassPathResource("user-response.json")));
```
### Created
```java
server.expect(requestTo("/users"))
.andExpect(method(HttpMethod.POST))
.andRespond(withCreatedEntity(URI.create("/users/1")));
```
### Error Response
```java
server.expect(requestTo("/users/999"))
.andRespond(withResourceNotFound());
server.expect(requestTo("/users"))
.andRespond(withServerError()
.body("Internal Server Error"));
server.expect(requestTo("/users"))
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
.body("{\"error\": \"Invalid input\"}"));
```
## Verifying Requests
```java
@Test
void shouldCallApi() {
server.expect(ExpectedCount.once(),
requestTo("https://api.example.com/data"))
.andRespond(withSuccess());
service.fetchData();
server.verify(); // Verify all expectations met
}
```
## Ignoring Extra Requests
```java
@Test
void shouldHandleMultipleCalls() {
server.expect(ExpectedCount.manyTimes(),
requestTo(matchesPattern("/api/.*")))
.andRespond(withSuccess());
// Multiple calls allowed
service.callApi();
service.callApi();
service.callApi();
}
```
## Reset Between Tests
```java
@BeforeEach
void setUp() {
server.reset();
}
```
## Testing Timeouts
```java
server.expect(requestTo("/slow-endpoint"))
.andRespond(withSuccess()
.body("{\"data\": \"test\"}")
.delay(100, TimeUnit.MILLISECONDS));
// Test timeout handling
```
## Best Practices
1. Always verify `server.verify()` at end of test
2. Use resource files for large JSON responses
3. Match on minimal set of request attributes
4. Reset server in @BeforeEach
5. Test error responses, not just success
6. Verify request body for POST/PUT calls

View File

@@ -0,0 +1,278 @@
# RestTestClient
Modern REST client testing with Spring Boot 4+ (replaces TestRestTemplate).
## Overview
RestTestClient is the modern alternative to TestRestTemplate in Spring Boot 4.0+. It provides a fluent, reactive API for testing REST endpoints.
## Setup
### Dependency (Spring Boot 4+)
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>
```
### Basic Configuration
```java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderIntegrationTest {
@Autowired
private RestTestClient restClient;
}
```
## HTTP Methods
### GET Request
```java
@Test
void shouldGetOrder() {
restClient
.get()
.uri("/orders/1")
.exchange()
.expectStatus()
.isOk()
.expectBody(Order.class)
.value(order -> {
assertThat(order.getId()).isEqualTo(1L);
assertThat(order.getStatus()).isEqualTo("PENDING");
});
}
```
### POST Request
```java
@Test
void shouldCreateOrder() {
OrderRequest request = new OrderRequest("Laptop", 2);
restClient
.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.exchange()
.expectStatus()
.isCreated()
.expectHeader()
.location("/orders/1")
.expectBody(Long.class)
.isEqualTo(1L);
}
```
### PUT Request
```java
@Test
void shouldUpdateOrder() {
restClient
.put()
.uri("/orders/1")
.body(new OrderUpdate("COMPLETED"))
.exchange()
.expectStatus()
.isOk();
}
```
### DELETE Request
```java
@Test
void shouldDeleteOrder() {
restClient
.delete()
.uri("/orders/1")
.exchange()
.expectStatus()
.isNoContent();
}
```
## Response Assertions
### Status Codes
```java
restClient
.get()
.uri("/orders/1")
.exchange()
.expectStatus()
.isOk() // 200
.isCreated() // 201
.isNoContent() // 204
.isBadRequest() // 400
.isNotFound() // 404
.is5xxServerError() // 5xx
.isEqualTo(200); // Specific code
```
### Headers
```java
restClient
.post()
.uri("/orders")
.exchange()
.expectHeader()
.location("/orders/1")
.contentType(MediaType.APPLICATION_JSON)
.exists("X-Request-Id")
.valueEquals("X-Api-Version", "v1");
```
### Body Assertions
```java
restClient
.get()
.uri("/orders/1")
.exchange()
.expectBody(Order.class)
.value(order -> assertThat(order.getId()).isEqualTo(1L))
.returnResult();
```
### JSON Path
```java
restClient
.get()
.uri("/orders")
.exchange()
.expectBody()
.jsonPath("$.content[0].id").isEqualTo(1)
.jsonPath("$.content[0].status").isEqualTo("PENDING")
.jsonPath("$.totalElements").isNumber();
```
## Request Configuration
### Headers
```java
restClient
.get()
.uri("/orders/1")
.header("Authorization", "Bearer token")
.header("X-Api-Key", "secret")
.exchange();
```
### Query Parameters
```java
restClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/orders")
.queryParam("status", "PENDING")
.queryParam("page", 0)
.queryParam("size", 10)
.build())
.exchange();
```
### Path Variables
```java
restClient
.get()
.uri("/orders/{id}", 1L)
.exchange();
```
## With MockMvc
RestTestClient can also work with MockMvc (no server startup):
```java
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestTestClient
class OrderMockMvcTest {
@Autowired
private RestTestClient restClient;
@Test
void shouldWorkWithMockMvc() {
// Uses MockMvc under the hood - no server startup
restClient
.get()
.uri("/orders/1")
.exchange()
.expectStatus()
.isOk();
}
}
```
## Comparison: RestTestClient vs TestRestTemplate
| Feature | RestTestClient | TestRestTemplate |
| ------- | -------------- | ---------------- |
| Style | Fluent/reactive | Imperative |
| Spring Boot | 4.0+ | All versions (deprecated in 4) |
| Assertions | Built-in | Manual |
| MockMvc support | Yes | No |
| Async | Native | Requires extra handling |
## Migration from TestRestTemplate
### Before (Deprecated)
```java
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldGetOrder() {
ResponseEntity<Order> response = restTemplate
.getForEntity("/orders/1", Order.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().getId()).isEqualTo(1L);
}
```
### After (RestTestClient)
```java
@Autowired
private RestTestClient restClient;
@Test
void shouldGetOrder() {
restClient
.get()
.uri("/orders/1")
.exchange()
.expectStatus()
.isOk()
.expectBody(Order.class)
.value(order -> assertThat(order.getId()).isEqualTo(1L));
}
```
## Best Practices
1. Use with @SpringBootTest(WebEnvironment.RANDOM_PORT) for real HTTP
2. Use with @AutoConfigureMockMvc for faster tests without server
3. Leverage fluent assertions for readability
4. Test both success and error scenarios
5. Verify headers for security/API versioning

View File

@@ -0,0 +1,181 @@
# Spring Boot 4.0 Migration
Key testing changes when migrating from Spring Boot 3.x to 4.0.
## Dependency Changes
### Modular Test Starters
Spring Boot 4.0 introduces modular test starters:
**Before (3.x):**
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
```
**After (4.0) - WebMvc Testing:**
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
```
**After (4.0) - REST Client Testing:**
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>
```
## Annotation Migration
### @MockBean → @MockitoBean
**Deprecated (3.x):**
```java
@MockBean
private OrderService orderService;
```
**New (4.0):**
```java
@MockitoBean
private OrderService orderService;
```
### @SpyBean → @MockitoSpyBean
**Deprecated (3.x):**
```java
@SpyBean
private PaymentGatewayClient paymentClient;
```
**New (4.0):**
```java
@MockitoSpyBean
private PaymentGatewayClient paymentClient;
```
## New Testing Features
### RestTestClient
Replaces TestRestTemplate (deprecated):
```java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderIntegrationTest {
@Autowired
private RestTestClient restClient;
@Test
void shouldCreateOrder() {
restClient
.post()
.uri("/orders")
.body(new OrderRequest("Product", 2))
.exchange()
.expectStatus()
.isCreated()
.expectHeader()
.location("/orders/1");
}
}
```
## JUnit 6 Support
Spring Boot 4.0 uses JUnit 6 by default:
- JUnit 4 is deprecated (use JUnit Vintage temporarily)
- All JUnit 5 features still work
- Remove JUnit 4 dependencies for clean migration
## Testcontainers 2.0
Module naming changed:
**Before (1.x):**
```xml
<artifactId>postgresql</artifactId>
```
**After (2.0):**
```xml
<artifactId>testcontainers-postgresql</artifactId>
```
## Non-Singleton Bean Mocking
Spring Framework 7 allows mocking prototype-scoped beans:
```java
@Component
@Scope("prototype")
public class OrderProcessor { }
@SpringBootTest
class OrderServiceTest {
@MockitoBean
private OrderProcessor orderProcessor; // Now works!
}
```
## SpringExtension Context Changes
Extension context is now test-method scoped by default.
If tests fail with @Nested classes:
```java
@SpringExtensionConfig(useTestClassScopedExtensionContext = true)
@SpringBootTest
class OrderTest {
// Use old behavior
}
```
## Migration Checklist
- [ ] Replace @MockBean with @MockitoBean
- [ ] Replace @SpyBean with @MockitoSpyBean
- [ ] Update Testcontainers dependencies to 2.0 naming
- [ ] Add modular test starters as needed
- [ ] Migrate TestRestTemplate to RestTestClient
- [ ] Remove JUnit 4 dependencies
- [ ] Update custom TestExecutionListener implementations
- [ ] Test @Nested class behavior
## Backward Compatibility
Use "classic" starters for gradual migration:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test-classic</artifactId>
<scope>test</scope>
</dependency>
```
This provides old behavior while you migrate incrementally.

View File

@@ -0,0 +1,203 @@
# Test Slices Overview
Quick reference for selecting the right Spring Boot test slice.
## Decision Matrix
| Annotation | Use When | Loads | Speed |
| ---------- | -------- | ----- | ----- |
| **None** (plain JUnit) | Testing pure business logic | Nothing | Fastest |
| `@WebMvcTest` | Controller + HTTP layer | Controllers, MVC, Jackson | Fast |
| `@DataJpaTest` | Repository queries | Repositories, JPA, DataSource | Fast |
| `@RestClientTest` | REST client code | RestTemplate/RestClient, Jackson | Fast |
| `@JsonTest` | JSON serialization | ObjectMapper only | Fastest slice |
| `@WebFluxTest` | Reactive controllers | Controllers, WebFlux | Fast |
| `@DataJdbcTest` | JDBC repositories | Repositories, JDBC | Fast |
| `@DataMongoTest` | MongoDB repositories | Repositories, MongoDB | Fast |
| `@DataRedisTest` | Redis repositories | Repositories, Redis | Fast |
| `@SpringBootTest` | Full integration | Entire application | Slow |
## Selection Guide
### Use NO Annotation (Plain Unit Test)
```java
class PriceCalculatorTest {
private PriceCalculator calculator = new PriceCalculator();
@Test
void shouldApplyDiscount() {
var result = calculator.applyDiscount(100, 0.1);
assertThat(result).isEqualTo(new BigDecimal("90.00"));
}
}
```
**When**: Pure business logic, no dependencies or simple dependencies mockable via constructor injection.
### Use @WebMvcTest
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired private MockMvcTester mvc;
@MockitoBean private OrderService orderService;
}
```
**When**: Testing request mapping, validation, JSON mapping, security, filters.
**What you get**: MockMvc, ObjectMapper, Spring Security (if present), exception handlers.
### Use @DataJpaTest
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
}
```
**When**: Testing custom JPA queries, entity mappings, transaction behavior, cascade operations.
**What you get**: Repository beans, EntityManager, TestEntityManager, transaction support.
### Use @RestClientTest
```java
@RestClientTest(WeatherService.class)
class WeatherServiceTest {
@Autowired private WeatherService weatherService;
@Autowired private MockRestServiceServer server;
}
```
**When**: Testing REST clients that call external APIs.
**What you get**: MockRestServiceServer to stub HTTP responses.
### Use @JsonTest
```java
@JsonTest
class OrderJsonTest {
@Autowired private JacksonTester<Order> json;
}
```
**When**: Testing custom serializers/deserializers, complex JSON mapping.
### Use @SpringBootTest
```java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderIntegrationTest {
@Autowired private RestTestClient restClient;
}
```
**When**: Testing full request flow, security filters, database interactions together.
**What you get**: Full application context, embedded server (optional), real beans.
## Common Mistakes
1. **Using @SpringBootTest for everything** - Slows down your test suite unnecessarily
2. **@WebMvcTest without mocking services** - Causes context loading failures
3. **@DataJpaTest with @MockBean** - Defeats the purpose (you want real repositories)
4. **Multiple slices in one test** - Each slice is a separate test class
## Java 25 Features in Tests
### Records for Test Data
```java
record OrderRequest(String product, int quantity) {}
record OrderResponse(Long id, String status, BigDecimal total) {}
```
### Pattern Matching in Tests
```java
@Test
void shouldHandleDifferentOrderTypes() {
var order = orderService.create(new OrderRequest("Product", 2));
switch (order) {
case PhysicalOrder po -> assertThat(po.getShippingAddress()).isNotNull();
case DigitalOrder do_ -> assertThat(do_.getDownloadLink()).isNotNull();
default -> throw new IllegalStateException("Unknown order type");
}
}
```
### Text Blocks for JSON
```java
@Test
void shouldParseComplexJson() {
var json = """
{
"id": 1,
"status": "PENDING",
"items": [
{"product": "Laptop", "price": 999.99},
{"product": "Mouse", "price": 29.99}
]
}
""";
assertThat(mvc.post().uri("/orders")
.contentType(APPLICATION_JSON)
.content(json))
.hasStatus(CREATED);
}
```
### Sequenced Collections
```java
@Test
void shouldReturnOrdersInSequence() {
var orders = orderRepository.findAll();
assertThat(orders.getFirst().getStatus()).isEqualTo("NEW");
assertThat(orders.getLast().getStatus()).isEqualTo("COMPLETED");
assertThat(orders.reversed().getFirst().getStatus()).isEqualTo("COMPLETED");
}
```
## Dependencies by Slice
```xml
<!-- WebMvcTest -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<!-- DataJpaTest -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- RestClientTest -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
```

View File

@@ -0,0 +1,234 @@
# Testcontainers JDBC
Testing JPA repositories with real databases using Testcontainers.
## Overview
Testcontainers provides real database instances in Docker containers for integration testing. More reliable than H2 for production parity.
## PostgreSQL Setup
### Dependencies
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
```
### Basic Test
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryPostgresTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
}
```
## MySQL Setup
```xml
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<scope>test</scope>
</dependency>
```
```java
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4");
```
## Multiple Databases
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class MultiDatabaseTest {
@Container
@ServiceConnection(name = "primary")
static PostgreSQLContainer<?> primaryDb = new PostgreSQLContainer<>("postgres:18");
@Container
@ServiceConnection(name = "analytics")
static PostgreSQLContainer<?> analyticsDb = new PostgreSQLContainer<>("postgres:18");
}
```
## Container Reuse (Speed Optimization)
Add to `~/.testcontainers.properties`:
```properties
testcontainers.reuse.enable=true
```
Then enable reuse in code:
```java
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18")
.withReuse(true);
```
## Database Initialization
### With SQL Scripts
```java
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18")
.withInitScript("schema.sql");
```
### With Flyway
```java
@SpringBootTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class MigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
@Autowired
private Flyway flyway;
@Test
void shouldApplyMigrations() {
flyway.migrate();
// Test code
}
}
```
## Advanced Configuration
### Custom Database/Schema
```java
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass")
.withInitScript("init-schema.sql");
```
### Wait Strategies
```java
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18")
.waitingFor(Wait.forLogMessage(".*database system is ready.*", 1));
```
## Test Example
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindOrdersByStatus() {
// Given
entityManager.persist(new Order("PENDING"));
entityManager.persist(new Order("COMPLETED"));
entityManager.flush();
// When
List<Order> pending = orderRepository.findByStatus("PENDING");
// Then
assertThat(pending).hasSize(1);
assertThat(pending.get(0).getStatus()).isEqualTo("PENDING");
}
@Test
void shouldSupportPostgresSpecificFeatures() {
// Can use Postgres-specific features like:
// - JSONB columns
// - Array types
// - Full-text search
}
}
```
## @DynamicPropertySource Alternative
If not using @ServiceConnection:
```java
@SpringBootTest
@Testcontainers
class OrderServiceTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
@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);
}
}
```
## Supported Databases
| Database | Container Class | Maven Artifact |
| -------- | --------------- | -------------- |
| PostgreSQL | PostgreSQLContainer | testcontainers-postgresql |
| MySQL | MySQLContainer | testcontainers-mysql |
| MariaDB | MariaDBContainer | testcontainers-mariadb |
| SQL Server | MSSQLServerContainer | testcontainers-mssqlserver |
| Oracle | OracleContainer | testcontainers-oracle-free |
| MongoDB | MongoDBContainer | testcontainers-mongodb |
## Best Practices
1. Use @ServiceConnection when possible (Spring Boot 3.1+)
2. Enable container reuse for faster local builds
3. Use specific versions (postgres:18) not latest
4. Keep container config in static field
5. Use @DataJpaTest with AutoConfigureTestDatabase.Replace.NONE

View File

@@ -0,0 +1,177 @@
# @WebMvcTest
Testing Spring MVC controllers with focused slice tests.
## Basic Structure
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
}
```
## What Gets Loaded
- The specified controller(s)
- Spring MVC infrastructure (HandlerMapping, HandlerAdapter)
- Jackson ObjectMapper (for JSON)
- Exception handlers (@ControllerAdvice)
- Spring Security filters (if on classpath)
- Validation (if on classpath)
## Testing GET Endpoints
```java
@Test
void shouldReturnOrder() {
var order = new Order(1L, "PENDING", BigDecimal.valueOf(99.99));
given(orderService.findById(1L)).willReturn(order);
assertThat(mvc.get().uri("/orders/1"))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson()
.extractingPath("$.status")
.isEqualTo("PENDING");
}
```
## Testing POST with Request Body
### Using Text Blocks (Java 25)
```java
@Test
void shouldCreateOrder() {
given(orderService.create(any(OrderRequest.class))).willReturn(1L);
var json = """
{
"product": "Product A",
"quantity": 2
}
""";
assertThat(mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "/orders/1");
}
```
### Using Records
```java
record OrderRequest(String product, int quantity) {}
@Test
void shouldCreateOrderWithRecord() {
var request = new OrderRequest("Product A", 2);
given(orderService.create(any())).willReturn(1L);
assertThat(mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(json.write(request).getJson()))
.hasStatus(HttpStatus.CREATED);
}
```
## Testing Validation Errors
```java
@Test
void shouldRejectInvalidOrder() {
var invalidJson = """
{
"product": "",
"quantity": -1
}
""";
assertThat(mvc.post().uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson()
.hasPath("$.errors");
}
```
## Testing Query Parameters
```java
@Test
void shouldFilterOrdersByStatus() {
assertThat(mvc.get().uri("/orders?status=PENDING"))
.hasStatusOk();
verify(orderService).findByStatus(OrderStatus.PENDING);
}
```
## Testing Path Variables
```java
@Test
void shouldCancelOrder() {
assertThat(mvc.put().uri("/orders/123/cancel"))
.hasStatusOk();
verify(orderService).cancel(123L);
}
```
## Testing with Security
```java
@Test
@WithMockUser(roles = "ADMIN")
void adminShouldDeleteOrder() {
assertThat(mvc.delete().uri("/orders/1"))
.hasStatus(HttpStatus.NO_CONTENT);
}
@Test
void anonymousUserShouldBeForbidden() {
assertThat(mvc.delete().uri("/orders/1"))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
```
## Multiple Controllers
```java
@WebMvcTest({OrderController.class, ProductController.class})
class WebLayerTest {
// Tests multiple controllers in one slice
}
```
## Excluding Auto-Configuration
```java
@WebMvcTest(OrderController.class)
@AutoConfigureMockMvc(addFilters = false) // Skip security filters
class OrderControllerWithoutSecurityTest {
// Tests without security filters
}
```
## Key Points
1. Always mock services with @MockitoBean
2. Use MockMvcTester for AssertJ-style assertions
3. Test HTTP semantics (status, headers, content-type)
4. Verify service method calls when side effects matter
5. Don't test business logic here - that's for unit tests
6. Leverage Java 25 text blocks for JSON payloads