Testing HTTP Layers Without Spinning Up the Full App
When you're building REST APIs with Spring Boot, you need confidence that your controllers route requests correctly, validate input, serialize responses, and enforce security rules. Controller tests with @WebMvcTest in Java & Spring Boot let you test the HTTP layer — routing, validation, JSON — in milliseconds without loading the entire application context. This approach isolates controller logic from service and database concerns, giving you fast feedback loops and clear separation of responsibility.
Why @WebMvcTest and Not Full Integration Tests?
Full integration tests that spin up the entire Spring context with embedded databases and real services are valuable, but they're slow and tightly coupled to infrastructure. Every time you run them, you're loading beans you don't need to test. A controller's job is to accept HTTP requests, validate input, call the service layer, and format responses. You don't need a real database or a real service for that—you need a mock.
@WebMvcTest loads only the MVC slice of your application: controllers, interceptors, filter chains, and Spring Security configuration. Services, repositories, and databases stay out. This gives you a focused test suite that runs in milliseconds and catches routing errors, JSON binding issues, and security misconfigurations before they reach integration tests.
Slice the Context — Only MVC Beans Load
When you annotate a test class with @WebMvcTest, Spring creates a minimal application context containing only beans needed for MVC testing: DispatcherServlet, controller advice, HandlerMapping, and your controllers. Everything else is excluded unless you explicitly include it.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private ProductService productService;
@Test
void shouldReturnAllProducts() throws Exception {
List<ProductDto> products = List.of(
new ProductDto(1L, "Laptop", 999.99),
new ProductDto(2L, "Mouse", 29.99)
);
when(productService.getAllProducts())
.thenReturn(products);
mvc.perform(get("/api/products"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Laptop"))
.andExpect(jsonPath("$[1].price").value(29.99));
verify(productService).getAllProducts();
}
}
Here, the ProductService is a mock bean. When the test runs, Spring injects MockMvc and the mock service into the controller. No database connection, no real service logic—just your controller code executing against stubbed dependencies. This test runs in tens of milliseconds.
If your controller depends on other beans like filters or interceptors, include them by adding them to the @WebMvcTest annotation or defining them as @MockBean or regular @Bean in a nested test configuration class. Most teams keep it simple: mock the service, test the controller.
Assert the Contract — Check Status Codes and JSON Fields
MockMvc provides a fluent API to perform HTTP requests and assert on responses. You can check status codes, response headers, JSON content, and redirect locations. This is where you verify that your controller fulfills its HTTP contract.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private ProductService productService;
@Test
void shouldReturnProductById() throws Exception {
ProductDto product = new ProductDto(1L, "Laptop", 999.99);
when(productService.getProductById(1L))
.thenReturn(Optional.of(product));
mvc.perform(get("/api/products/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Laptop"))
.andExpect(jsonPath("$.price").value(999.99));
}
@Test
void shouldReturn404WhenProductNotFound() throws Exception {
when(productService.getProductById(99L))
.thenReturn(Optional.empty());
mvc.perform(get("/api/products/99"))
.andExpect(status().isNotFound());
}
@Test
void shouldReturnCreatedProductWithLocation() throws Exception {
CreateProductRequest request = new CreateProductRequest("Monitor", 299.99);
ProductDto created = new ProductDto(5L, "Monitor", 299.99);
when(productService.createProduct("Monitor", 299.99))
.thenReturn(created);
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Monitor",
"price": 299.99
}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(5L));
}
}
Notice how each test focuses on one scenario: success, not found, created. We use jsonPath to drill into nested JSON structures, check headers for location-based redirects, and verify content types. This level of detail catches serialization bugs and route misconfigurations that would otherwise slip into production.
Validation Cases — Send Invalid Bodies and Assert 400 with Field Errors
Spring's validation framework (using @Valid and @Constraint annotations) is part of the MVC layer. @WebMvcTest includes validation, so you can test that your controller rejects invalid input and returns a 400 Bad Request with detailed error information.
@WebMvcTest(ProductController.class)
class ProductValidationTest {
@Autowired
private MockMvc mvc;
@MockBean
private ProductService productService;
@Test
void shouldRejectEmptyName() throws Exception {
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "",
"price": 299.99
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[?(@.field == 'name')].message")
.value("Name must not be blank"));
}
@Test
void shouldRejectNegativePrice() throws Exception {
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Laptop",
"price": -50.00
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[?(@.field == 'price')].message")
.value("Price must be positive"));
}
@Test
void shouldRejectMissingRequiredField() throws Exception {
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"price": 299.99
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("name"));
}
}
These tests ensure that your validation annotations (@NotBlank, @Positive, @NotNull) are wired correctly into your request DTO and that your exception handler returns the expected error format. This is critical because clients depend on error responses to provide good user feedback.
Security Included — Use @WithMockUser and Verify 401/403 Paths
Spring Security is included in the MVC slice. You can use @WithMockUser and other test annotations from spring-security-test to simulate authenticated users and verify that your security rules work as intended.
@WebMvcTest(ProductController.class)
class ProductSecurityTest {
@Autowired
private MockMvc mvc;
@MockBean
private ProductService productService;
@Test
void shouldReturn401WhenUnauthenticated() throws Exception {
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Laptop",
"price": 999.99
}
"""))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldAllowAdminToCreateProduct() throws Exception {
ProductDto created = new ProductDto(1L, "Laptop", 999.99);
when(productService.createProduct("Laptop", 999.99))
.thenReturn(created);
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Laptop",
"price": 999.99
}
"""))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(roles = "USER")
void shouldForbidUserFromDeletingProduct() throws Exception {
mvc.perform(delete("/api/products/1"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldAllowAdminToDeleteProduct() throws Exception {
mvc.perform(delete("/api/products/1"))
.andExpect(status().isNoContent());
verify(productService).deleteProductById(1L);
}
}
By using @WithMockUser(roles = "..."), you can test role-based authorization without requiring a real authentication provider or user database. This ensures that your @PreAuthorize and HTTP Security configurations actually work. Testing security at the controller level catches misconfigurations early.
Common Mistakes
- Forgetting to mock the service. If you don't use @MockBean, Spring will try to wire the real service bean, which defeats the purpose of @WebMvcTest. Always mock service and repository dependencies.
- Testing business logic in controller tests. Controller tests should verify routing, validation, and JSON serialization. If you're testing complex business logic, write service unit tests instead. Keep controller tests focused on the HTTP contract.
- Not stubbing mock returns. If you mock a service but forget to set up the when/thenReturn behavior, the mock returns null or empty values, leading to confusing failures. Always explicitly configure mock behavior before performing the request.
- Using real database or file I/O in @WebMvcTest. If your controller makes real database calls or reads files, you're not testing in isolation. Refactor to inject dependencies and mock them.
- Asserting on implementation details instead of contracts. Don't test that your service was called with exact parameters unless that's part of your public contract. Focus on the response status and JSON output.
Key Takeaways
- @WebMvcTest loads only the MVC slice of your Spring application, excluding services, repositories, and databases. Tests run in milliseconds.
- Mock service dependencies using @MockBean so you can test the controller in isolation and control behavior deterministically.
- Use MockMvc to perform HTTP requests and assert on status codes, headers, and JSON content using a fluent, readable API.
- Test validation by sending invalid input and asserting that your controller returns 400 Bad Request with detailed field errors.
- Include security tests using @WithMockUser to verify that role-based authorization works correctly and unauthenticated users are rejected.
- Keep controller tests focused on the HTTP contract: routing, validation, response format, and security. Delegate business logic testing to service unit tests.






Comments
Loading comments…