Testing Strategy
Tests are a guarantee of quality—writing code without tests is like driving without a seatbelt; sooner or later, an accident will happen.
1. What You'll Learn
@SpringBootTestIntegration Testing and@WebMvcTestSlicing Testing- Mockito
@MockBean/@SpyBeanMocking Dependencies @DataJpaTestData Layer Testing and Embedded H2 Database- TestContainers: Containerized Integration Testing for MySQL and Redis
- Alice increased the test coverage of OrderFlow's core order placement process to over 80%
2. A True Story of a Quality Engineer
(1) Pain Point: Fixing one bug leads to three more
Alice modified the order cancellation logic, thinking it would only affect the cancellation feature, but it ended up causing issues with inventory restoration as well, which in turn affected the reporting statistics. Every time she changes the code, she's on edge because there are no automated tests to verify regression. Bob is frequently woken up in the middle of the night by calls for emergency fixes, and the team spends 30% of their time each week fixing bugs.
(2) Solutions for Automated Testing
Write good tests to provide a "safety net" when modifying code:
@SpringBootTest
class OrderServiceTest {
@Test
void shouldCancelOrderAndRestoreStock() {
// Given: product with stock 10, order with quantity 3
// When: cancel order
// Then: stock restored to 13, order status is CANCELLED
}
}
(3) Revenue
Alice wrote 50 test cases for the OrderFlow core process, achieving 85% code coverage. Every time the code is modified, the tests run automatically, and regression issues are detected within five minutes—so Bob no longer has to fix bugs in the middle of the night.
3. The Testing Pyramid
(1) Three-Tier Testing Strategy
graph LR
A["Unit Tests<br/>70% - Fast, Isolated<br/>JUnit + Mockito"] --> B["Integration Tests<br/>20% - Medium Speed<br/>@SpringBootTest + H2"]
B --> C["E2E Tests<br/>10% - Slow, Full Stack<br/>TestContainers + REST Assured"]
| Level | Percentage | Speed | Dependencies | Test Content |
|---|---|---|---|---|
| Unit Testing | 70% | Millisecond-level | Mock | Business Logic |
| Integration Testing | 20% | Seconds | H2/TestContainers | Component Interaction |
| E2E Testing | 10% | Minutes | Full Environment | Business Process |
(1) ▶ Example: Unit Testing
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private ProductRepository productRepository;
@InjectMocks
private OrderServiceImpl orderService;
@Test
void shouldCreateOrderSuccessfully() {
// Given
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// When
Order order = orderService.createOrder(1L, 3);
// Then
assertThat(order).isNotNull();
assertThat(product.getStock()).isEqualTo(7);
verify(orderRepository).save(any(Order.class));
}
@Test
void shouldThrowWhenInsufficientStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
verify(orderRepository, never()).save(any());
}
}
Output:
// Execution Successful
4. @WebMvcTest Slicing Tests
(1) Controller-Level Testing
(1) ▶ Example: Controller Slicing Test
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrderById() throws Exception {
OrderResponse order = new OrderResponse(
1L, "bob", "PENDING", BigDecimal.valueOf(999),
List.of(), Instant.now());
when(orderService.getOrder(1L)).thenReturn(order);
mockMvc.perform(get("/api/v1/orders/1")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
when(orderService.getOrder(999L))
.thenThrow(new ResourceNotFoundException("Order", 999L));
mockMvc.perform(get("/api/v1/orders/999")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
}
@Test
void shouldReturn400WhenValidationFails() throws Exception {
String body = """
{"productId": null, "quantity": 0}
""";
mockMvc.perform(post("/api/v1/orders")
.with(httpBasic("bob", "pass123"))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
}
Output:
// Execution Successful
| Test Annotation | Loading Scope | Applicable Level |
|---|---|---|
@WebMvcTest |
Controller + Web Layer | Controller |
@DataJpaTest |
Repository + JPA Layer | Repository |
@SpringBootTest |
Full ApplicationContext | Integration Testing |
5. @DataJpaTest Data Layer Testing
(1) ▶ Example: Repository Testing
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByNameContaining() {
entityManager.persist(new Product("Laptop Pro", BigDecimal.valueOf(1299), 50));
entityManager.persist(new Product("Laptop Air", BigDecimal.valueOf(899), 30));
entityManager.persist(new Product("Wireless Mouse", BigDecimal.valueOf(29), 200));
List<Product> results = productRepository.findByNameContaining("Laptop");
assertThat(results).hasSize(2);
}
@Test
void shouldFindOutOfStockProducts() {
Product outOfStock = new Product("Old Model", BigDecimal.valueOf(99), 0);
entityManager.persist(outOfStock);
entityManager.persist(new Product("New Model", BigDecimal.valueOf(199), 10));
List<Product> results = productRepository.findOutOfStockProducts();
assertThat(results).hasSize(1);
assertThat(results.get(0).getName()).isEqualTo("Old Model");
}
@Test
void shouldCheckStockBeforeDeduction() {
Product product = entityManager.persist(
new Product("Laptop", BigDecimal.valueOf(999), 5));
Product found = productRepository.findById(product.getId()).orElseThrow();
found.deductStock(3);
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(2);
}
}
Output:
// Execution Successful
6. TestContainers: Containerized Integration Testing
(1) Live Database Testing
(1) ▶ Example: MySQL TestContainer
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Output:
// Execution Successful
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("orderflow_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCreateOrderAndDeductStock() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
Order order = orderService.createOrder(product.getId(), 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(7);
}
}
(2) ▶ Example: Redis TestContainer
@SpringBootTest
@Testcontainers
class RedisCacheIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCacheProductInRedis() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
// First call: database query
productService.getProduct(product.getId());
// Second call: Redis cache hit
Product cached = productService.getProduct(product.getId());
assertThat(cached).isNotNull();
}
}
Output:
// Execution Successful
7. Test Coverage
(1) ▶ Example: JaCoCo Configuration
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
Output:
// Execution Successful
| Coverage Dimension | Objective | Description |
|---|---|---|
| Line Coverage | ≥ 80% | Core Business Code |
| Branch Coverage | ≥ 70% | if/else Logic |
| Method Coverage | ≥ 80% | Public Methods |
8. Comprehensive Example: Testing the Core Order Placement Process in OrderFlow
// OrderServiceTest.java (Unit Test)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private ProductRepository productRepository;
@InjectMocks private OrderServiceImpl orderService;
@Test
void createOrder_success() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Order order = orderService.createOrder(1L, 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
assertThat(product.getStock()).isEqualTo(7);
}
@Test
void createOrder_insufficientStock_throws() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
}
@Test
void cancelOrder_success_restoresStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 7);
Order order = new Order();
order.setStatus("PENDING");
order.addItem(product, 3);
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
orderService.cancelOrder(1L);
assertThat(order.getStatus()).isEqualTo("CANCELLED");
assertThat(product.getStock()).isEqualTo(10);
}
@Test
void cancelOrder_nonPending_throws() {
Order order = new Order();
order.setStatus("SHIPPED");
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
assertThatThrownBy(() -> orderService.cancelOrder(1L))
.isInstanceOf(OrderStateException.class);
}
}
// OrderControllerTest.java (Slice Test)
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
@WithMockUser(roles = "CUSTOMER")
void createOrder_validRequest_returns201() throws Exception {
when(orderService.createOrder(any(), eq("bob"))).thenReturn(
new OrderResponse(1L, "bob", "PENDING", BigDecimal.valueOf(999), List.of(), Instant.now()));
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void createOrder_unauthenticated_returns401() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isUnauthorized());
}
}
❓ FAQ
static field), so subsequent tests reuse them directly. Typical overhead: 30 seconds for the first run, and less than 5 seconds for subsequent runs. We recommend prewarming images in the CI environment.SyncTaskExecutor) to simplify testing.📖 Summary
- Testing Pyramid: 70% unit tests + 20% integration tests + 10% E2E tests
- Use
@ExtendWith(MockitoExtension.class)+@Mockfor unit testing - Controller for testing:
@WebMvcTest+MockMvc - Repository for testing:
@DataJpaTest+ H2 - TestContainers: Test using real database containers to eliminate H2 discrepancies
- JaCoCo monitors test coverage; core business code ≥ 80%
📝 Exercises
-
Basic Exercise (Difficulty: ⭐): Write five unit tests for OrderService that cover scenarios such as creating an order, insufficient inventory, canceling an order, status exceptions, and resources not found.
-
Advanced Exercise (Difficulty ⭐⭐): Use
@WebMvcTestto write slice tests for the OrderController, covering scenarios such as normal requests, validation failures, 404 errors, and 401 (Unauthorized) errors. Use@DataJpaTestto write query tests for the ProductRepository. -
Challenge (Difficulty: ⭐⭐⭐): Integrate TestContainers (MySQL + Redis) and write an end-to-end integration test—covering the entire process from creating a product, placing an order and deducting inventory, Redis cache hits, to canceling an order and restoring inventory, with a coverage rate of ≥ 80%.



