β¬ οΈ Back to Controller Overview
Controller Testing Strategy
Overview
This document outlines testing patterns for REST controllers,
including unit tests (@WebMvcTest) and integration
tests (@SpringBootTest). Covers endpoint
validation, authorization checks, and response shape
verification.
Test Location:
src/test/java/.../controller/
Frameworks: JUnit 5, Mockito, Spring Test,
RestAssured
Testing Pyramid
Full app + database"] B --> BX["10% of tests
Slow, realistic"] A --> C["Integration Tests
@SpringBootTest
MockMvc + Real Spring Context"] C --> CX["20% of tests
Medium speed"] A --> D["Unit Tests
@WebMvcTest
Mocked Services"] D --> DX["70% of tests
Fast feedback"] style A fill:#fff9c4 style B fill:#ffcdd2 style C fill:#ffe0b2 style D fill:#c8e6c9
Unit Testing with @WebMvcTest
Purpose
Test controller request/response mapping without service layer.
Template
@WebMvcTest(SupplierController.class)
class SupplierControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SupplierService supplierService;
@Test
void testGetSupplier_Returns200WithData() throws Exception {
// ARRANGE
SupplierDTO mockSupplier = new SupplierDTO(
"SUP-001", "ACME Corp", "acme@example.com", "active", null
);
when(supplierService.getSupplier("SUP-001"))
.thenReturn(Optional.of(mockSupplier));
// ACT & ASSERT
mockMvc.perform(
get("/api/suppliers/SUP-001")
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("SUP-001"))
.andExpect(jsonPath("$.name").value("ACME Corp"));
// VERIFY
verify(supplierService, times(1)).getSupplier("SUP-001");
}
}Characteristics
| Aspect | Detail |
|---|---|
| Speed | Very fast (~50ms per test) |
| Isolation | Services mocked, pure MVC testing |
| Scope | Controller layer only (routing, binding, serialization) |
| Deployment | @WebMvcTest creates minimal Spring context |
| Mocking | @MockBean for services, repositories |
Common Assertions
// Status codes
.andExpect(status().isOk())
.andExpect(status().isCreated())
.andExpect(status().isBadRequest())
.andExpect(status().isUnauthorized())
.andExpect(status().isForbidden())
.andExpect(status().isNotFound())
// JSON path assertions
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.id").value("SUP-001"))
.andExpect(jsonPath("$.suppliers[0].name").value("ACME"))
.andExpect(jsonPath("$", hasSize(3)))
// Headers
.andExpect(header().exists("Location"))
.andExpect(header().string("Content-Type", containsString("application/json")))
// Content
.andExpect(content().json("{\"id\":\"SUP-001\"}"))Integration Testing with @SpringBootTest
Purpose
Test controllers with real services and database operations.
Template
@SpringBootTest
@ActiveProfiles("test") // Uses H2, application-test.yml
class SupplierControllerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private SupplierRepository supplierRepository;
@BeforeEach
void setup() {
supplierRepository.deleteAll();
supplierRepository.save(new Supplier(
"SUP-001", "ACME Corp", "acme@example.com", "active"
));
}
@Test
void testGetSupplier_WithRealDatabase() {
ResponseEntity<SupplierDTO> response = restTemplate.getForEntity(
"/api/suppliers/SUP-001",
SupplierDTO.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().getName()).isEqualTo("ACME Corp");
}
}Characteristics
| Aspect | Detail |
|---|---|
| Speed | Slower (~500ms per test), but realistic |
| Isolation | Full Spring context, real services, H2 database |
| Scope | End-to-end from controller to database |
| Deployment | Full @SpringBootTest application context |
| Database | H2 in-memory (from application-test.yml) |
Authorization Testing
Testing @PreAuthorize Annotations
@WebMvcTest(SupplierController.class)
class SupplierControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SupplierService supplierService;
@Test
@WithAnonymousUser // No authentication
void testCreateSupplier_WithAnonymous_Returns403() throws Exception {
mockMvc.perform(
post("/api/suppliers")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New Corp\"}")
)
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "USER") // USER role
void testCreateSupplier_WithUserRole_Returns403() throws Exception {
mockMvc.perform(
post("/api/suppliers")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New Corp\"}")
)
.andExpect(status().isForbidden()); // Only ADMIN allowed
}
@Test
@WithMockUser(roles = "ADMIN") // ADMIN role
void testCreateSupplier_WithAdminRole_Returns201() throws Exception {
SupplierDTO newSupplier = new SupplierDTO(
"SUP-999", "New Corp", "new@example.com", "active", null
);
when(supplierService.createSupplier(any()))
.thenReturn(newSupplier);
mockMvc.perform(
post("/api/suppliers")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newSupplier))
)
.andExpect(status().isCreated());
}
}Authorization Annotations
| Annotation | Use Case |
|---|---|
@WithAnonymousUser |
Test public endpoints & 403 paths |
@WithMockUser |
Test authenticated USER behavior |
@WithMockUser(roles="ADMIN") |
Test ADMIN-only endpoints |
@WithSecurityContext |
Custom security context setup |
Endpoint Testing by Controller
Supplier Controller Tests
Location:
src/test/java/.../controller/supplier/
SupplierControllerTest
βββ testGetSuppliers_Returns200WithPagination
βββ testGetSupplier_WithValidId_Returns200
βββ testGetSupplier_WithInvalidId_Returns404
βββ testCreateSupplier_WithAdminRole_Returns201
βββ testCreateSupplier_WithUserRole_Returns403
βββ testUpdateSupplier_WithValidData_Returns200
βββ testDeleteSupplier_SoftDelete_Returns204
βββ testSearchSupplier_ByName_ReturnsList
SupplierControllerSecurityTest
βββ testCreateSupplier_WithAnonymousUser_Returns403
βββ testUpdateSupplier_WithAnonymousUser_Returns403
βββ testDeleteSupplier_WithAnonymousUser_Returns403
Inventory Item Controller Tests
Location:
src/test/java/.../controller/inventoryitem/
InventoryItemControllerCreateReadTest
βββ testGetItems_WithDefaultPagination_Returns20Items
βββ testGetItems_WithSortByPrice_ReturnsSorted
βββ testGetItem_WithValidId_Returns200
βββ testCreateItem_WithValidData_Returns201
βββ testUpdateItem_WithValidData_Returns200
βββ testDeleteItem_SoftDelete_Returns204
InventoryItemControllerPatchTest
βββ testUpdateStock_IncreasesQuantity_UpdatesStockHistory
βββ testUpdateStock_DecreasesQuantity_CreatesNegativeEntry
βββ testUpdatePrice_WithValidPrice_Returns200
βββ testSearchItem_ByName_ReturnsList
InventoryItemControllerRenameTest
βββ testRenameItem_AdminSuccess_Returns200
βββ testRenameItem_UserRole_Forbidden_Returns403
βββ testRenameItem_Unauthenticated_Returns401
βββ testRenameItem_NotFound_Returns404
βββ testRenameItem_DuplicateName_Returns409
βββ testRenameItem_EmptyName_Returns400
βββ testRenameItem_WhitespaceOnlyName_Returns400
βββ testRenameItem_SpecialCharacters_Returns200
βββ testRenameItem_CaseInsensitiveDuplicate_Returns409
βββ testRenameItem_MissingNameParam_Returns400
InventoryItemControllerSecurityTest
βββ testUpdateStock_WithUserRole_Returns403
βββ testCreateItem_WithDemoMode_Returns405
βββ testDeleteItem_WithUserRole_Returns403
Stock History Controller Tests
Location:
src/test/java/.../controller/stockhistory/
StockHistoryControllerTest
βββ testGetStockHistory_Paginated_Returns20Items
βββ testGetStockHistoryById_WithValidId_Returns200
βββ testGetHistoryByItem_Filtered_ReturnsList
βββ testGetHistoryBySupplier_Filtered_ReturnsList
βββ testGetHistoryWithDateRange_Filtered_ReturnsList
StockHistoryControllerSecurityTest
βββ testGetStockHistory_PublicDemo_Returns200
Auth Controller Tests
Location:
src/test/java/.../controller/auth/
AuthControllerTest
βββ testGetMe_WithAuthenticatedUser_Returns200WithProfile
βββ testGetMe_WithAnonymousUser_RedirectsToLogin
βββ testLogout_InvalidatesSession_Returns302Redirect
AuthControllerSecurityTest
βββ testGetMe_CreatesSessionCookie_SameSiteNone
βββ testLogout_ClearsSessionCookie
βββ testOAuth2Login_AssignsUserRole_ByDefault
Analytics Controller Tests
Location:
src/test/java/.../controller/analytics/
AnalyticsControllerTest
βββ testDashboardSummary_CalculatesKPIs_Returns200
βββ testFinancialSummary_WithDateRange_Returns200
βββ testStockValue_TimeSeries_ReturnsList
βββ testStockPerSupplier_Distribution_ReturnsList
βββ testPriceTrends_WithSupplierFilter_ReturnsList
βββ testMonthlyMovement_AggregatesData_ReturnsList
AnalyticsControllerSecurityTest
βββ testDashboardSummary_PublicDemo_Returns200
Testing PATCH Endpoints: Update Price & Rename
PATCH /api/inventory/{id}/price Pattern
For updating item price, test successful update and price validation:
@WebMvcTest(InventoryItemController.class)
class InventoryItemControllerPriceTest {
@MockBean
private InventoryItemService service;
@Test
@WithMockUser
void testUpdatePrice_WithValidPrice_Returns200() throws Exception {
InventoryItemDTO updated = new InventoryItemDTO(
"ITEM-001", "Widget", 100, new BigDecimal("24.99"), 50, "SUP-001", "ACTIVE"
);
when(service.updatePrice("ITEM-001", new BigDecimal("24.99")))
.thenReturn(updated);
mockMvc.perform(
patch("/api/inventory/ITEM-001/price")
.param("price", "24.99")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.price").value(24.99));
}
}PATCH /api/inventory/{id}/name Pattern - Rename with Authorization
Testing rename requires testing three critical aspects:
- Authorization - Only ADMIN can rename
- Validation - Name cannot be empty/duplicate
- Error Mapping -
IllegalArgumentExceptionβ proper HTTP status
@WebMvcTest(InventoryItemController.class)
class InventoryItemControllerRenameTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private InventoryItemService service;
// Test 1: Admin success
@Test
@WithMockUser(roles = "ADMIN")
void testRenameItem_AdminSuccess_Returns200() throws Exception {
InventoryItemDTO renamed = new InventoryItemDTO(
"ITEM-001", "Widget Pro", 100, new BigDecimal("19.99"), 50, "SUP-001", "ACTIVE"
);
when(service.renameItem("ITEM-001", "Widget Pro"))
.thenReturn(renamed);
mockMvc.perform(
patch("/api/inventory/ITEM-001/name")
.param("name", "Widget Pro")
.with(csrf()) // CSRF token required for state-changing requests
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Widget Pro"));
verify(service).renameItem("ITEM-001", "Widget Pro");
}
// Test 2: User forbidden (non-admin)
@Test
@WithMockUser(roles = "USER") // User role, not admin
void testRenameItem_UserRole_Forbidden_Returns403() throws Exception {
mockMvc.perform(
patch("/api/inventory/ITEM-001/name")
.param("name", "Widget Pro")
.with(csrf())
)
.andExpect(status().isForbidden());
verify(service, never()).renameItem(any(), any()); // Service should not be called
}
// Test 3: Duplicate name detection
@Test
@WithMockUser(roles = "ADMIN")
void testRenameItem_DuplicateName_Returns409Conflict() throws Exception {
when(service.renameItem("ITEM-001", "Existing Item Name"))
.thenThrow(new IllegalArgumentException(
"An item with this name already exists for this supplier"
));
mockMvc.perform(
patch("/api/inventory/ITEM-001/name")
.param("name", "Existing Item Name")
.with(csrf())
)
.andExpect(status().isConflict()); // 409, not 404
}
// Test 4: Empty name validation
@Test
@WithMockUser(roles = "ADMIN")
void testRenameItem_EmptyName_Returns400BadRequest() throws Exception {
when(service.renameItem("ITEM-001", " "))
.thenThrow(new IllegalArgumentException("Item name cannot be empty"));
mockMvc.perform(
patch("/api/inventory/ITEM-001/name")
.param("name", " ") // Whitespace-only
.with(csrf())
)
.andExpect(status().isBadRequest()); // 400, not 404
}
// Test 5: Item not found
@Test
@WithMockUser(roles = "ADMIN")
void testRenameItem_NotFound_Returns404() throws Exception {
when(service.renameItem("INVALID-ID", "New Name"))
.thenThrow(new NoSuchElementException("Item not found"));
mockMvc.perform(
patch("/api/inventory/INVALID-ID/name")
.param("name", "New Name")
.with(csrf())
)
.andExpect(status().isNotFound()); // 404
}
}Key Points for Rename Testing:
| Aspect | Strategy |
|---|---|
| Authorization | Use @WithMockUser(roles="ADMIN") for success,
@WithMockUser(roles="USER") for failure |
| Error Mapping | Service throws IllegalArgumentException,
controller maps to 400/404/409 based on message |
| CSRF Token | Include .with(csrf()) for state-changing PATCH
requests |
| Validation | Test empty name, whitespace, duplicates, not found separately |
| Verification | Use verify(service) to ensure service is called
only when authorized |
Verify Paginated Responses
@Test
void testGetSuppliers_WithPagination_ReturnsPageStructure() throws Exception {
mockMvc.perform(
get("/api/suppliers?page=0&size=20")
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content[0].id").exists())
.andExpect(jsonPath("$.pageable.pageNumber").value(0))
.andExpect(jsonPath("$.pageable.pageSize").value(20))
.andExpect(jsonPath("$.totalElements").isNumber());
}Verify Single Resource Response
@Test
void testGetSupplier_ReturnsDTOStructure() throws Exception {
mockMvc.perform(get("/api/suppliers/SUP-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("SUP-001"))
.andExpect(jsonPath("$.name").value("ACME Corp"))
.andExpect(jsonPath("$.email").value("acme@example.com"))
.andExpect(jsonPath("$.status").value("active"));
}Verify Error Response
@Test
void testGetSupplier_WithInvalidId_Returns404WithErrorMessage() throws Exception {
mockMvc.perform(get("/api/suppliers/INVALID"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("SUPPLIER_NOT_FOUND"))
.andExpect(jsonPath("$.message").exists())
.andExpect(jsonPath("$.timestamp").exists());
}Mocking Services
Mock Service Dependencies
@WebMvcTest(SupplierController.class)
class SupplierControllerTest {
@MockBean
private SupplierService supplierService;
// Mock responses for service calls
@Test
void testGetSupplier_DelegatestoService() throws Exception {
SupplierDTO mock = new SupplierDTO("SUP-001", "ACME", ...);
when(supplierService.getSupplier("SUP-001"))
.thenReturn(Optional.of(mock));
mockMvc.perform(get("/api/suppliers/SUP-001"))
.andExpect(status().isOk());
verify(supplierService).getSupplier("SUP-001");
}
}Mock Exception Throwing
@Test
void testGetSupplier_WithServiceException_Returns500() throws Exception {
when(supplierService.getSupplier(any()))
.thenThrow(new DatabaseException("Connection timeout"));
mockMvc.perform(get("/api/suppliers/INVALID"))
.andExpect(status().isInternalServerError());
}Running Tests
Run All Tests
# Maven
mvn clean test
# Gradle
gradle clean testRun Specific Test Class
mvn test -Dtest=SupplierControllerTestRun Specific Test Method
mvn test -Dtest=SupplierControllerTest#testGetSupplier_Returns200WithDataRun with Coverage
mvn clean test jacoco:report
# Report: target/site/jacoco/index.htmlTest Coverage Goals
| Layer | Target | Rationale |
|---|---|---|
| Controller | >80% | Business logic verified |
| Service | >85% | Critical business rules |
| Repository | >70% | Query logic tested |
| Overall | >75% | Adequate safety net |
Best Practices
β
DO:
β’ One test per scenario (single assertion focus)
β’ Use @BeforeEach for common setup
β’ Name tests clearly: testMethod_Condition_Expected
β’ Mock external dependencies (@MockBean)
β’ Test authorization separately from business logic
β’ Use @ActiveProfiles("test") for H2 database
β’ Verify method calls with verify()
β DON'T:
β’ Test framework code (Spring handles it)
β’ Create tight coupling between tests
β’ Use Thread.sleep() (use testcontainers instead)
β’ Skip authorization tests
β’ Mix @WebMvcTest with real database
β’ Test multiple scenarios in one test
Summary
| Test Type | Use Case | Speed | Isolation | Coverage |
|---|---|---|---|---|
| Unit (@WebMvcTest) | Controller logic, routing | Fast | High | Shallow (controller only) |
| Integration (@SpringBootTest) | End-to-end flows | Slow | Low | Deep (full stack) |
| Authorization | Role checks, @PreAuthorize | Medium | Medium | Security coverage |
Recommended Ratio: 70% unit + 20% integration + 10% E2E tests.