rrezartprebreza/spring-boot-testing-pyramid
> Use when writing tests of any kind — unit, slice, or integration. Covers test structure, naming conventions, Mockito patterns, @WebMvcTest, @DataJpaTest, and Testcontainers setup.
npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill testing-pyramid
Unit Tests — fast, no Spring context, mock dependencies (70%)
Slice Tests — partial Spring context (@WebMvcTest, @DataJpaTest) (20%)
Integration Tests — full context + real DB via Testcontainers (10%)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private InventoryService inventoryService;
@InjectMocks private OrderService orderService;
@Test
void createOrder_whenItemsAvailable_shouldSaveAndReturnOrder() {
// Given
var request = new CreateOrderRequest("[email protected]", List.of(new OrderItemRequest(UUID.randomUUID(), 2)));
var savedOrder = Order.create("[email protected]");
when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);
doNothing().when(inventoryService).reserve(any());
// When
Order result = orderService.createOrder(request);
// Then
assertThat(result).isNotNull();
assertThat(result.getCustomerEmail()).isEqualTo("[email protected]");
verify(inventoryService).reserve(request.items());
verify(orderRepository).save(any(Order.class));
}
@Test
void createOrder_whenInventoryUnavailable_shouldThrowException() {
// Given
var request = new CreateOrderRequest("[email protected]", List.of());
doThrow(new InsufficientInventoryException("Out of stock"))
.when(inventoryService).reserve(any());
// When / Then
assertThatThrownBy(() -> orderService.createOrder(request))
.isInstanceOf(InsufficientInventoryException.class)
.hasMessage("Out of stock");
}
}
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockitoBean OrderService orderService;
@Test
@WithMockUser(roles = "USER")
void createOrder_withValidRequest_shouldReturn201() throws Exception {
// Given
var request = new CreateOrderRequest("[email protected]", List.of());
var order = Order.create("[email protected]");
when(orderService.createOrder(any())).thenReturn(order);
// When / Then
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.customerEmail").value("[email protected]"));
}
@Test
@WithMockUser
void createOrder_withInvalidRequest_shouldReturn400() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}")) // missing required fields
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED"));
}
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // use real DB (Testcontainers)
@Import(TestcontainersConfig.class)
class OrderRepositoryTest {
@Autowired OrderRepository orderRepository;
@Test
void findByStatus_shouldReturnMatchingOrders() {
// Given
var order1 = orderRepository.save(Order.create("[email protected]"));
var order2 = orderRepository.save(Order.create("[email protected]"));
order2.ship(); // change status
orderRepository.save(order2);
// When
List<Order> pending = orderRepository.findByStatus(OrderStatus.PENDING, Pageable.unpaged()).getContent();
// Then
assertThat(pending).hasSize(1);
assertThat(pending.get(0).getCustomerEmail()).isEqualTo("[email protected]");
}
}
// Shared config — reuse container across tests
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}
// Full integration test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Import(TestcontainersConfig.class)
class OrderIntegrationTest {
@Autowired RestTestClient restTestClient;
@Autowired OrderRepository orderRepository;
@Test
void createAndRetrieveOrder_endToEnd() {
// Create
var createRequest = new CreateOrderRequest("[email protected]", List.of());
restTestClient.post()
.uri("/api/v1/orders")
.body(createRequest)
.exchange()
.expectStatus().isCreated()
.expectBody(ApiResponse.class);
// Retrieve
// ... assert persisted correctly
}
}
// Method name: methodName_condition_expectedBehavior
createOrder_whenItemsAvailable_shouldSaveOrder()
findById_whenOrderNotFound_shouldThrowNotFoundException()
login_withInvalidCredentials_shouldReturn401()
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest for everything — use slices for speedH2 in-memory DB for @DataJpaTest — use Testcontainers for accuracy@MockBean — removed in Boot 4; use @MockitoBean (and @MockitoSpyBean for spies)TestRestTemplate by habit — prefer RestTestClient with @AutoConfigureRestTestClient for Boot 4 HTTP integration testsMockito.mock() instead of @Mock — use annotations with @ExtendWith(MockitoExtension.class)@WithMockUser on controller tests — security filter blocks all requestsassertEquals from JUnit — use AssertJ (assertThat(...).isEqualTo(...))test_createOrder() — use createOrder_condition_expected() patternTake rrezartprebreza/spring-boot-testing-pyramid from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.