Testing Pyramid
Structure
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%)
Unit Tests — Services
java@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("user@example.com", List.of(new OrderItemRequest(UUID.randomUUID(), 2))); var savedOrder = Order.create("user@example.com"); 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("user@example.com"); verify(inventoryService).reserve(request.items()); verify(orderRepository).save(any(Order.class)); } @Test void createOrder_whenInventoryUnavailable_shouldThrowException() { // Given var request = new CreateOrderRequest("user@example.com", 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"); } }
Slice Tests — Controllers (@WebMvcTest)
java// POSTs need .with(csrf()): @WebMvcTest applies Boot's default security (CSRF on); // @WithMockUser only authenticates. Add @Import(SecurityConfig.class) to test your real rules. // import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; @MockitoBean OrderService orderService; // Spring Boot 3.4+: @MockBean is deprecated @Test @WithMockUser(roles = "USER") void createOrder_withValidRequest_shouldReturn201() throws Exception { // Given var request = new CreateOrderRequest("user@example.com", List.of(new OrderItemRequest(UUID.randomUUID(), 1))); // items are @NotEmpty var order = Order.create("user@example.com"); when(orderService.createOrder(any())).thenReturn(order); // When / Then mockMvc.perform(post("/api/v1/orders").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.customerEmail").value("user@example.com")); } @Test @WithMockUser void createOrder_withInvalidRequest_shouldReturn400() throws Exception { mockMvc.perform(post("/api/v1/orders").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) // missing required fields .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); } }
Slice Tests — Repositories (@DataJpaTest)
java@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("a@example.com")); var order2 = orderRepository.save(Order.create("b@example.com")); 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("a@example.com"); } }
Integration Tests — Testcontainers
This application-level example assumes the first-party JWT setup and order API from
the related skills. Supply src/test/resources/fixtures/order-user-and-product.sql for
your application's schema: an enabled USER named buyer@example.com and an in-stock
product with ID 00000000-0000-0000-0000-000000000001. Run it only against the disposable
test database. Adapt the fixtures and response DTO to your application.
Load the real persisted user before signing the token. @WithMockUser does not authenticate
a request sent over HTTP to a random-port server. For OAuth2 resource servers, use a test
issuer instead of introducing this application's JWT service.
java// 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) @Import(TestcontainersConfig.class) class OrderIntegrationTest { @Autowired TestRestTemplate restTemplate; @Autowired OrderRepository orderRepository; @Autowired JwtService jwtService; @Autowired UserDetailsService userDetailsService; private static final UUID PRODUCT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); private static final ParameterizedTypeReference<ApiResponse<OrderResponse>> RESPONSE_TYPE = new ParameterizedTypeReference<>() {}; @Test @Sql("/fixtures/order-user-and-product.sql") void createAndRetrieveOrder_endToEnd() { var user = userDetailsService.loadUserByUsername("buyer@example.com"); String token = jwtService.generateAccessToken(user); var createRequest = new CreateOrderRequest("buyer@example.com", List.of(new CreateOrderRequest.OrderItemRequest(PRODUCT_ID, 1))); var headers = new HttpHeaders(); headers.setBearerAuth(token); headers.setContentType(MediaType.APPLICATION_JSON); var createResponse = restTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(createRequest, headers), RESPONSE_TYPE); assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); var created = createResponse.getBody(); assertThat(created).isNotNull(); assertThat(created.success()).isTrue(); assertThat(created.data()).isNotNull(); UUID id = created.data().id(); assertThat(id).isNotNull(); assertThat(orderRepository.findById(id)).isPresent(); var getResponse = restTemplate.exchange("/api/v1/orders/{id}", HttpMethod.GET, new HttpEntity<>(headers), RESPONSE_TYPE, id); assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); var retrieved = getResponse.getBody(); assertThat(retrieved).isNotNull(); assertThat(retrieved.success()).isTrue(); assertThat(retrieved.data()).isNotNull(); assertThat(retrieved.data().id()).isEqualTo(id); assertThat(retrieved.data().status()).isEqualTo(created.data().status()); assertThat(retrieved.data().items()).hasSize(1); assertThat(retrieved.data().items().get(0).productId()).isEqualTo(PRODUCT_ID); assertThat(retrieved.data().items().get(0).quantity()).isEqualTo(1); } }
Naming Convention
// Method name: methodName_condition_expectedBehavior createOrder_whenItemsAvailable_shouldSaveOrder() findById_whenOrderNotFound_shouldThrowNotFoundException() login_withInvalidCredentials_shouldReturn401()
Testcontainers Dependencies
xml<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>
Gotchas
- Agent uses
@SpringBootTestfor everything — use slices for speed - Agent uses
H2in-memory DB for@DataJpaTest— use Testcontainers for accuracy - Agent uses
@MockBean— deprecated since Spring Boot 3.4; use@MockitoBean(and@MockitoSpyBeanfor spies) - Agent uses
Mockito.mock()instead of@Mock— use annotations with@ExtendWith(MockitoExtension.class) - Agent forgets
@WithMockUseron controller tests — security filter blocks all requests with 401 - Agent POSTs in
@WebMvcTestwithout.with(csrf())— Boot's default security enables CSRF, so every write returns 403 even with@WithMockUser - Agent uses
assertEqualsfrom JUnit — use AssertJ (assertThat(...).isEqualTo(...)) - Agent names tests
test_createOrder()— usecreateOrder_condition_expected()pattern


