mirror of
https://github.com/github/awesome-copilot.git
synced 2026-03-20 08:05:12 +00:00
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:
189
skills/spring-boot-testing/SKILL.md
Normal file
189
skills/spring-boot-testing/SKILL.md
Normal file
@@ -0,0 +1,189 @@
|
||||
---
|
||||
name: spring-boot-testing
|
||||
description: Expert Spring Boot 4 testing specialist that selects the best Spring Boot testing techniques for your situation with Junit 6 and AssertJ.
|
||||
---
|
||||
|
||||
# Spring Boot Testing
|
||||
|
||||
This skill provides expert guide for testing Spring Boot 4 applications with modern patterns and best practices.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Test Pyramid**: Unit (fast) > Slice (focused) > Integration (complete)
|
||||
2. **Right Tool**: Use the narrowest slice that gives you confidence
|
||||
3. **AssertJ Style**: Fluent, readable assertions over verbose matchers
|
||||
4. **Modern APIs**: Prefer MockMvcTester and RestTestClient over legacy alternatives
|
||||
|
||||
## Which Test Slice?
|
||||
|
||||
| Scenario | Annotation | Reference |
|
||||
|----------|------------|-----------|
|
||||
| Controller + HTTP semantics | `@WebMvcTest` | [references/webmvctest.md](references/webmvctest.md) |
|
||||
| Repository + JPA queries | `@DataJpaTest` | [references/datajpatest.md](references/datajpatest.md) |
|
||||
| REST client + external APIs | `@RestClientTest` | [references/restclienttest.md](references/restclienttest.md) |
|
||||
| JSON (de)serialization | `@JsonTest` | [references/test-slices-overview.md](references/test-slices-overview.md) |
|
||||
| Full application | `@SpringBootTest` | [references/test-slices-overview.md](references/test-slices-overview.md) |
|
||||
|
||||
## Test Slices Reference
|
||||
|
||||
- [references/test-slices-overview.md](references/test-slices-overview.md) - Decision matrix and comparison
|
||||
- [references/webmvctest.md](references/webmvctest.md) - Web layer with MockMvc
|
||||
- [references/datajpatest.md](references/datajpatest.md) - Data layer with Testcontainers
|
||||
- [references/restclienttest.md](references/restclienttest.md) - REST client testing
|
||||
|
||||
## Testing Tools Reference
|
||||
|
||||
- [references/mockmvc-tester.md](references/mockmvc-tester.md) - AssertJ-style MockMvc (3.2+)
|
||||
- [references/mockmvc-classic.md](references/mockmvc-classic.md) - Traditional MockMvc (pre-3.2)
|
||||
- [references/resttestclient.md](references/resttestclient.md) - Spring Boot 4+ REST client
|
||||
- [references/mockitobean.md](references/mockitobean.md) - Mocking dependencies
|
||||
|
||||
## Assertion Libraries
|
||||
|
||||
- [references/assertj-basics.md](references/assertj-basics.md) - Scalars, strings, booleans, dates
|
||||
- [references/assertj-collections.md](references/assertj-collections.md) - Lists, Sets, Maps, arrays
|
||||
|
||||
## Testcontainers
|
||||
|
||||
- [references/testcontainers-jdbc.md](references/testcontainers-jdbc.md) - PostgreSQL, MySQL, etc.
|
||||
|
||||
## Test Data Generation
|
||||
|
||||
- [references/instancio.md](references/instancio.md) - Generate complex test objects (3+ properties)
|
||||
|
||||
## Performance & Migration
|
||||
|
||||
- [references/context-caching.md](references/context-caching.md) - Speed up test suites
|
||||
- [references/sb4-migration.md](references/sb4-migration.md) - Spring Boot 4.0 changes
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
```
|
||||
Testing a controller endpoint?
|
||||
Yes → @WebMvcTest with MockMvcTester
|
||||
|
||||
Testing repository queries?
|
||||
Yes → @DataJpaTest with Testcontainers (real DB)
|
||||
|
||||
Testing business logic in service?
|
||||
Yes → Plain JUnit + Mockito (no Spring context)
|
||||
|
||||
Testing external API client?
|
||||
Yes → @RestClientTest with MockRestServiceServer
|
||||
|
||||
Testing JSON mapping?
|
||||
Yes → @JsonTest
|
||||
|
||||
Need full integration test?
|
||||
Yes → @SpringBootTest with minimal context config
|
||||
```
|
||||
|
||||
## Spring Boot 4 Highlights
|
||||
|
||||
- **RestTestClient**: Modern alternative to TestRestTemplate
|
||||
- **@MockitoBean**: Replaces @MockBean (deprecated)
|
||||
- **MockMvcTester**: AssertJ-style assertions for web tests
|
||||
- **Modular starters**: Technology-specific test starters
|
||||
- **Context pausing**: Automatic pausing of cached contexts (Spring Framework 7)
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Code Complexity Assessment
|
||||
|
||||
When a method or class is too complex to test effectively:
|
||||
|
||||
1. **Analyze complexity** - If you need more than 5-7 test cases to cover a single method, it's likely too complex
|
||||
2. **Recommend refactoring** - Suggest breaking the code into smaller, focused functions
|
||||
3. **User decision** - If the user agrees to refactor, help identify extraction points
|
||||
4. **Proceed if needed** - If the user decides to continue with the complex code, implement tests despite the difficulty
|
||||
|
||||
**Example of refactoring recommendation:**
|
||||
```java
|
||||
// Before: Complex method hard to test
|
||||
public Order processOrder(OrderRequest request) {
|
||||
// Validation, discount calculation, payment, inventory, notification...
|
||||
// 50+ lines of mixed concerns
|
||||
}
|
||||
|
||||
// After: Refactored into testable units
|
||||
public Order processOrder(OrderRequest request) {
|
||||
validateOrder(request);
|
||||
var order = createOrder(request);
|
||||
applyDiscount(order);
|
||||
processPayment(order);
|
||||
updateInventory(order);
|
||||
sendNotification(order);
|
||||
return order;
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Code Redundancy
|
||||
|
||||
Create helper methods for commonly used objects and mock setup to enhance readability and maintainability.
|
||||
|
||||
### Test Organization with @DisplayName
|
||||
|
||||
Use descriptive display names to clarify test intent:
|
||||
|
||||
```java
|
||||
@Test
|
||||
@DisplayName("Should calculate discount for VIP customer")
|
||||
void shouldCalculateDiscountForVip() { }
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject order when customer has insufficient credit")
|
||||
void shouldRejectOrderForInsufficientCredit() { }
|
||||
```
|
||||
|
||||
### Test Coverage Order
|
||||
|
||||
Always structure tests in this order:
|
||||
|
||||
1. **Main scenario** - The happy path, most common use case
|
||||
2. **Other paths** - Alternative valid scenarios, edge cases
|
||||
3. **Exceptions/Errors** - Invalid inputs, error conditions, failure modes
|
||||
|
||||
### Test Production Scenarios
|
||||
|
||||
Write tests with real production scenarios in mind. This makes tests more relatable and helps understand code behavior in actual production cases.
|
||||
|
||||
### Test Coverage Goals
|
||||
|
||||
Aim for 80% code coverage as a practical balance between quality and effort. Higher coverage is beneficial but not the only goal.
|
||||
|
||||
Use Jacoco maven plugin for coverage reporting and tracking.
|
||||
|
||||
|
||||
**Coverage Rules:**
|
||||
- 80+% coverage minimum
|
||||
- Focus on meaningful assertions, not just execution
|
||||
|
||||
**What to Prioritize:**
|
||||
1. Business-critical paths (payment processing, order validation)
|
||||
2. Complex algorithms (pricing, discount calculations)
|
||||
3. Error handling (exceptions, edge cases)
|
||||
4. Integration points (external APIs, databases)
|
||||
|
||||
## Dependencies (Spring Boot 4)
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- For WebMvc tests -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- For Testcontainers -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
207
skills/spring-boot-testing/references/assertj-basics.md
Normal file
207
skills/spring-boot-testing/references/assertj-basics.md
Normal 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
|
||||
183
skills/spring-boot-testing/references/assertj-collections.md
Normal file
183
skills/spring-boot-testing/references/assertj-collections.md
Normal 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
|
||||
115
skills/spring-boot-testing/references/context-caching.md
Normal file
115
skills/spring-boot-testing/references/context-caching.md
Normal 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
|
||||
197
skills/spring-boot-testing/references/datajpatest.md
Normal file
197
skills/spring-boot-testing/references/datajpatest.md
Normal 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())
|
||||
230
skills/spring-boot-testing/references/instancio.md
Normal file
230
skills/spring-boot-testing/references/instancio.md
Normal 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)
|
||||
232
skills/spring-boot-testing/references/mockitobean.md
Normal file
232
skills/spring-boot-testing/references/mockitobean.md
Normal 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
|
||||
206
skills/spring-boot-testing/references/mockmvc-classic.md
Normal file
206
skills/spring-boot-testing/references/mockmvc-classic.md
Normal 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
|
||||
311
skills/spring-boot-testing/references/mockmvc-tester.md
Normal file
311
skills/spring-boot-testing/references/mockmvc-tester.md
Normal 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
|
||||
227
skills/spring-boot-testing/references/restclienttest.md
Normal file
227
skills/spring-boot-testing/references/restclienttest.md
Normal 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
|
||||
278
skills/spring-boot-testing/references/resttestclient.md
Normal file
278
skills/spring-boot-testing/references/resttestclient.md
Normal 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
|
||||
181
skills/spring-boot-testing/references/sb4-migration.md
Normal file
181
skills/spring-boot-testing/references/sb4-migration.md
Normal 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.
|
||||
203
skills/spring-boot-testing/references/test-slices-overview.md
Normal file
203
skills/spring-boot-testing/references/test-slices-overview.md
Normal 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>
|
||||
```
|
||||
234
skills/spring-boot-testing/references/testcontainers-jdbc.md
Normal file
234
skills/spring-boot-testing/references/testcontainers-jdbc.md
Normal 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
|
||||
177
skills/spring-boot-testing/references/webmvctest.md
Normal file
177
skills/spring-boot-testing/references/webmvctest.md
Normal 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
|
||||
Reference in New Issue
Block a user