Spring Boot Testing: Slice Annotations, MockMvc, and @WebMvcTest
Master Spring Boot slice testing with @WebMvcTest, MockMvc, and @DataJpaTest.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Spring Boot 2.7+ or 3.x project
- ✓Basic understanding of Spring MVC controllers
- ✓JUnit 5 and Mockito basics
• Use @WebMvcTest to only load web layer beans (controllers, filters, etc.) and mock all services • MockMvc lets you perform HTTP requests and assert responses without starting a full server • Slice annotations like @DataJpaTest and @JsonTest load only relevant context for faster, focused tests • Always prefer slice tests over @SpringBootTest for unit-level web layer validation • Combine with @MockBean for service dependencies to isolate controller logic
Think of slice testing like testing a car's engine without the entire car. You put the engine on a stand, connect a dummy fuel line, and rev it. You don't need the wheels, seats, or radio. @WebMvcTest is that engine stand for your web layer — it only starts the controller and related web components, mocking everything else. This makes tests lightning fast and pinpoint accurate.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Let's cut the crap: most Spring Boot testing tutorials tell you to slap @SpringBootTest on everything and call it a day. That works for a 5-controller pet project. In production, with 200+ beans, that approach is a nightmare. Your CI pipeline becomes a joke — 45-minute test suites, flaky failures because some unrelated bean initialization fails, and developers losing faith in the test suite.
I've been there. In 2018, we had a payment-processing system where a full @SpringBootTest suite took 47 minutes. Developers stopped running tests locally. They'd push, wait for CI, and pray. The breaking point was when a change in the email template module broke all web layer tests because of a bean wiring issue. That's when we migrated to slice annotations.
Slice annotations are Spring Boot's secret weapon for fast, isolated testing. @WebMvcTest loads only the web layer — controllers, filters, interceptors, and Jackson configuration. @DataJpaTest loads only JPA repositories. @JsonTest loads only JSON serialization. Each test loads a fraction of the context, running in seconds instead of minutes.
This article covers @WebMvcTest and MockMvc in depth, with patterns I've used in real SaaS billing systems processing millions of API calls daily. You'll learn how to test controllers in isolation, mock dependencies cleanly, and avoid the pitfalls that make slice tests a pain. By the end, your test suite will be fast, reliable, and actually useful for catching regressions.
Setting Up @WebMvcTest: The Right Way
Let's start with a real example. I have a BillingController that handles subscription billing in a SaaS platform. It exposes endpoints for creating subscriptions, fetching invoices, and handling webhooks. The controller depends on a BillingService interface.
Here's the controller:
```java @RestController @RequestMapping("/api/v1/billing") public class BillingController {
private final BillingService billingService;
public BillingController(BillingService billingService) { this.billingService = billingService; }
@PostMapping("/subscriptions") public ResponseEntity<SubscriptionResponse> createSubscription( @Valid @RequestBody CreateSubscriptionRequest request) { SubscriptionResponse response = billingService.createSubscription(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); }
@GetMapping("/subscriptions/{id}") public ResponseEntity<SubscriptionResponse> getSubscription(@PathVariable String id) { return billingService.findSubscription(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } } ```
Now the test. The key is to specify only the controller you're testing. This avoids loading unrelated beans and keeps the context small.
```java @WebMvcTest(BillingController.class) class BillingControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private BillingService billingService;
@Test void createSubscription_shouldReturn201() throws Exception { CreateSubscriptionRequest request = new CreateSubscriptionRequest( "plan_premium", "cust_123", "pm_card_visa" ); SubscriptionResponse response = new SubscriptionResponse("sub_456", "active", Instant.now());
given(billingService.createSubscription(any(CreateSubscriptionRequest.class))) .willReturn(response);
mockMvc.perform(post("/api/v1/billing/subscriptions") .contentType(MediaType.APPLICATION_JSON) .content("{\"planId\":\"plan_premium\",\"customerId\":\"cust_123\",\"paymentMethodId\":\"pm_card_visa\"}")) .andExpect(status().isCreated()) .andExpect(jsonPath("$.subscriptionId").value("sub_456")) .andExpect(jsonPath("$.status").value("active")); } } ```
Notice: I use @MockBean for the service. This creates a Mockito mock that replaces the real bean in the application context. The test is completely isolated — no database, no external APIs, just the controller logic.
What the Official Docs Won't Tell You
The Spring Boot docs tell you @WebMvcTest loads only web layer beans. What they don't tell you is the silent killers: security auto-configuration, Jackson serialization quirks, and exception handler interference.
First, @WebMvcTest automatically configures Spring Security if it's on the classpath. This means all your endpoints are secured by default. If you have a custom security filter that checks JWT tokens, it will be loaded. This can break tests if you don't explicitly disable security or mock authentication.
Second, Jackson configuration is loaded. If you have custom serializers or deserializers, they will affect your test results. This is usually what you want, but it can surprise you when a field is excluded by a custom filter.
Third, @ControllerAdvice exception handlers are loaded. If you have a global exception handler that transforms exceptions into specific error responses, it will be active in your tests. This is actually great — you're testing the real error handling flow.
Here's how to handle security in @WebMvcTest:
``java @WebMvcTest(BillingController.class) @AutoConfigureMockMvc(addFilters = false) // Disable security filters class BillingControllerTest { // ... } ``
Or better, use @WithMockUser from Spring Security Test:
```java @WebMvcTest(BillingController.class) class BillingControllerTest {
@Test @WithMockUser(roles = "ADMIN") void adminCanCreateSubscription() throws Exception { // ... } } ```
Another gotcha: if your controller uses @Valid or @Validated, the validation will run in tests. Make sure your test requests include valid data, or test validation errors explicitly.
Finally, MockMvc by default does not follow redirects. If your controller returns a 302, you need to configure MockMvc to follow or handle it explicitly.
MockMvc Request Builders: POST, GET, PUT, DELETE
MockMvc provides fluent builders for all HTTP methods. The key is to use them consistently and understand how to set headers, request bodies, and parameters.
For POST and PUT requests with JSON bodies, always set the content type explicitly. I've seen tests pass because MockMvc defaults to application/x-www-form-urlencoded, and then the controller fails in production because it expects JSON.
``java mockMvc.perform(post("/api/v1/billing/subscriptions") .contentType(MediaType.APPLICATION_JSON) .content("{\"planId\":\"plan_premium\"}")) ``
For GET requests with query parameters, use param() or params():
``java mockMvc.perform(get("/api/v1/billing/subscriptions") .param("status", "active") .param("page", "0") .param("size", "20")) ``
For path variables:
``java mockMvc.perform(get("/api/v1/billing/subscriptions/{id}", "sub_123")) ``
For headers (important for API keys or correlation IDs):
``java mockMvc.perform(post("/api/v1/billing/webhooks/stripe") .header("Stripe-Signature", "t=123,v1=abc") .contentType(MediaType.APPLICATION_JSON) .content(webhookPayload)) ``
For file uploads:
```java MockMultipartFile file = new MockMultipartFile( "file", "invoice.pdf", "application/pdf", "PDF content".getBytes() );
mockMvc.perform(multipart("/api/v1/billing/invoices/upload") .file(file) .param("invoiceId", "inv_123")) ```
One pattern I always use: create helper methods for common request building. This reduces duplication and makes tests more readable.
```java private ResultActions performPost(String url, Object requestBody) throws Exception { return mockMvc.perform(post(url) .contentType(MediaType.APPLICATION_JSON) .content(asJsonString(requestBody))); }
private String asJsonString(Object obj) { try { return new ObjectMapper().writeValueAsString(obj); } catch (Exception e) { throw new RuntimeException(e); } } ```
This helper approach saved us hours when we changed the base URL from /api/v1 to /api/v2. We just updated one method.
Response Assertions with jsonPath and Hamcrest Matchers
MockMvc's andExpect() method is your primary assertion tool. You chain matchers to verify status codes, headers, and response bodies. The jsonPath matcher is incredibly powerful for JSON responses.
Basic status code assertions:
``java mockMvc.perform(...) .andExpect(``status().isOk()) .andExpect(status().isCreated()) .andExpect(status().isNotFound()) .andExpect(status().isBadRequest()) .andExpect(status().isUnauthorized());
Header assertions:
``java mockMvc.perform(...) .andExpect(``header().string("Location", "/api/v1/billing/subscriptions/sub_456")) .andExpect(header().string("Content-Type", "application/json"));
jsonPath assertions for nested JSON:
``java mockMvc.perform(...) .andExpect(jsonPath("$.subscriptionId").value("sub_456")) .andExpect(jsonPath("$.status").value("active")) .andExpect(jsonPath("$.createdAt").isNotEmpty()) .andExpect(jsonPath("$.customer.name").value("John Doe")) .andExpect(jsonPath("$.items[*].planId").value(hasItem("plan_premium"))); ``
For arrays:
``java mockMvc.perform(...) .andExpect(jsonPath("$.invoices", hasSize(2))) .andExpect(jsonPath("$.invoices[0].id").value("inv_1")) .andExpect(jsonPath("$.invoices[?(@.status == 'paid')]").exists()); ``
For error responses:
``java mockMvc.perform(post("/api/v1/billing/subscriptions") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(``status().isBadRequest()) .andExpect(jsonPath("$.errors[0].field").value("planId")) .andExpect(jsonPath("$.errors[0].message").value("must not be null"));
One thing that bit us: jsonPath uses Jayway JsonPath under the hood. The syntax is different from XPath or JavaScript. For example, the filter expression [?(@.status == 'paid')] uses single quotes for string comparison. Double quotes will fail.
Also, jsonPath is lenient by default. If the path doesn't exist, it returns null rather than throwing an error. Use assertThat(jsonPath(...), notNullValue()) if you want to ensure a field exists.
Testing Exception Handling and Validation Errors
A controller is only as good as its error handling. You must test both happy paths and error paths. @WebMvcTest loads @ControllerAdvice classes, so your global exception handlers are active.
Let's say you have a GlobalExceptionHandler:
```java @RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(SubscriptionNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(SubscriptionNotFoundException ex) { ErrorResponse error = new ErrorResponse("NOT_FOUND", ex.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); }
@ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) { List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors().stream() .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage())) .toList(); ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", "Invalid request", fieldErrors); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); } } ```
Test the not-found case:
```java @Test void getSubscription_notFound_shouldReturn404() throws Exception { given(billingService.findSubscription("nonexistent")) .willReturn(Optional.empty());
mockMvc.perform(get("/api/v1/billing/subscriptions/nonexistent")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.errorCode").value("NOT_FOUND")) .andExpect(jsonPath("$.message").value("Subscription not found: nonexistent")); } ```
Test validation errors:
```java @Test void createSubscription_missingPlanId_shouldReturn400() throws Exception { String invalidRequest = "{\"customerId\":\"cust_123\"}"; // Missing planId
mockMvc.perform(post("/api/v1/billing/subscriptions") .contentType(MediaType.APPLICATION_JSON) .content(invalidRequest)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) .andExpect(jsonPath("$.fieldErrors[0].field").value("planId")) .andExpect(jsonPath("$.fieldErrors[0].message").value("must not be null")); } ```
Test unexpected exceptions (500):
```java @Test void createSubscription_serviceThrows_shouldReturn500() throws Exception { given(billingService.createSubscription(any())) .willThrow(new RuntimeException("Database connection failed"));
mockMvc.perform(post("/api/v1/billing/subscriptions") .contentType(MediaType.APPLICATION_JSON) .content("{\"planId\":\"plan_premium\",\"customerId\":\"cust_123\"}")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.errorCode").value("INTERNAL_ERROR")); } ```
This is where slice tests shine. You're testing the actual error handling flow, including serialization of error responses. No database needed.
Advanced MockMvc: Async Requests, File Uploads, and Custom Filters
Real applications have async endpoints, file uploads, and custom filters. MockMvc handles all of these, but with some nuances.
For async requests (CompletableFuture, DeferredResult, or Spring's async support):
```java @Test void createSubscription_async_shouldReturn202() throws Exception { given(billingService.createSubscriptionAsync(any())) .willReturn(CompletableFuture.completedFuture(new SubscriptionResponse("sub_456", "active", Instant.now())));
MvcResult result = mockMvc.perform(post("/api/v1/billing/subscriptions/async") .contentType(MediaType.APPLICATION_JSON) .content("{\"planId\":\"plan_premium\"}")) .andExpect(request().asyncStarted()) .andExpect(request().asyncResult(instanceOf(SubscriptionResponse.class))) .andReturn();
mockMvc.perform(asyncDispatch(result)) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.subscriptionId").value("sub_456")); } ```
For file uploads:
```java @Test void uploadInvoice_shouldReturn200() throws Exception { MockMultipartFile file = new MockMultipartFile( "file", "invoice.pdf", "application/pdf", "%PDF-1.4...".getBytes() );
given(billingService.processInvoice(anyString(), any())).willReturn(true);
mockMvc.perform(multipart("/api/v1/billing/invoices/upload") .file(file) .param("invoiceId", "inv_123")) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)); } ```
If you have custom filters (e.g., request logging, rate limiting):
``java @WebMvcTest(controllers = BillingController.class, filters = RequestLoggingFilter.class) class BillingControllerWithFilterTest { // ... } ``
Or if you need to exclude filters:
``java @WebMvcTest(controllers = BillingController.class) @AutoConfigureMockMvc(addFilters = false) class BillingControllerWithoutSecurityTest { // ... } ``
One pattern I use for testing custom filters: create a simple controller that just echoes back the request, then test the filter behavior through that controller.
```java @WebMvcTest(controllers = EchoController.class, filters = RateLimitingFilter.class) class RateLimitingFilterTest {
@Autowired private MockMvc mockMvc;
@Test void rateLimitExceeded_shouldReturn429() throws Exception { // Send 10 requests quickly for (int i = 0; i < 10; i++) { mockMvc.perform(get("/echo")); } // 11th request should be blocked mockMvc.perform(get("/echo")) .andExpect(status().isTooManyRequests()); } } ```
Slice Annotations Beyond @WebMvcTest: @DataJpaTest, @JsonTest, @RestClientTest
@WebMvcTest is just one slice annotation. Spring Boot provides several others for focused testing:
@DataJpaTest: Loads only JPA repositories, EntityManager, and DataSource. Does not load full Spring MVC or services. Perfect for testing repository queries and entity mappings.
```java @DataJpaTest class SubscriptionRepositoryTest {
@Autowired private SubscriptionRepository subscriptionRepository;
@Autowired private TestEntityManager entityManager;
@Test void findByStatus_shouldReturnActiveSubscriptions() { Subscription active = new Subscription("sub_1", "active", Instant.now()); Subscription canceled = new Subscription("sub_2", "canceled", Instant.now()); entityManager.persist(active); entityManager.persist(canceled);
List<Subscription> result = subscriptionRepository.findByStatus("active");
assertThat(result).hasSize(1); assertThat(result.get(0).getId()).isEqualTo("sub_1"); } } ```
@JsonTest: Loads only Jackson configuration. Tests serialization and deserialization in isolation.
```java @JsonTest class SubscriptionResponseJsonTest {
@Autowired private JacksonTester<SubscriptionResponse> json;
@Test void serialize_shouldIncludeAllFields() throws Exception { SubscriptionResponse response = new SubscriptionResponse( "sub_456", "active", Instant.parse("2024-01-15T10:00:00Z") );
assertThat(json.write(response)).isEqualToJson("subscription-response.json"); assertThat(json.write(response)).hasJsonPathStringValue("$.subscriptionId"); }
@Test void deserialize_shouldHandleNullFields() throws Exception { String jsonContent = "{\"subscriptionId\":\"sub_456\",\"status\":\"active\"}";
SubscriptionResponse response = json.parseObject(jsonContent);
assertThat(response.getSubscriptionId()).isEqualTo("sub_456"); assertThat(response.getCreatedAt()).isNull(); } } ```
@RestClientTest: Tests REST clients (RestTemplate or WebClient) with mock servers.
```java @RestClientTest(BillingApiClient.class) class BillingApiClientTest {
@Autowired private MockRestServiceServer server;
@Autowired private BillingApiClient client;
@Test void fetchSubscription_shouldReturnResponse() { server.expect(requestTo("/subscriptions/sub_456")) .andRespond(withSuccess( "{\"subscriptionId\":\"sub_456\",\"status\":\"active\"}", MediaType.APPLICATION_JSON ));
SubscriptionResponse response = client.fetchSubscription("sub_456");
assertThat(response.getSubscriptionId()).isEqualTo("sub_456"); server.verify(); } } ```
Each slice annotation reduces context loading by 80-90% compared to @SpringBootTest. Mix and match them for different test types.
Integration Testing with @SpringBootTest and Testcontainers
Slice tests are great, but you still need integration tests that verify the full stack works together. The key is to use @SpringBootTest sparingly and only for critical paths.
For database integration tests, use Testcontainers instead of H2 or embedded databases. H2 is not PostgreSQL, and I've seen countless bugs where H2-specific SQL works in tests but fails in production.
```java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @Testcontainers class BillingControllerIntegrationTest {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); }
@Autowired private TestRestTemplate restTemplate;
@Test void createAndRetrieveSubscription() { CreateSubscriptionRequest request = new CreateSubscriptionRequest( "plan_premium", "cust_123", "pm_card_visa" );
ResponseEntity<SubscriptionResponse> createResponse = restTemplate.postForEntity( "/api/v1/billing/subscriptions", request, SubscriptionResponse.class );
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); String subscriptionId = createResponse.getBody().getSubscriptionId();
ResponseEntity<SubscriptionResponse> getResponse = restTemplate.getForEntity( "/api/v1/billing/subscriptions/{id}", SubscriptionResponse.class, subscriptionId );
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(getResponse.getBody().getStatus()).isEqualTo("active"); } } ```
For external API integration tests, use WireMock:
```java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @WireMockTest(httpPort = 8089) class PaymentGatewayIntegrationTest {
@Test void processPayment_shouldCallStripe() { WireMock.stubFor(post("/v1/charges") .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody("{\"id\":\"ch_123\",\"status\":\"succeeded\"}")));
ResponseEntity<PaymentResponse> response = restTemplate.postForEntity( "/api/v1/billing/payments", new PaymentRequest("pm_card_visa", BigDecimal.valueOf(29.99)), PaymentResponse.class );
assertThat(response.getBody().getStatus()).isEqualTo("succeeded"); WireMock.verify(postRequestedFor(urlEqualTo("/v1/charges"))); } } ```
My rule: 80% slice tests, 15% integration tests with Testcontainers, 5% full end-to-end tests. This gives you speed where you need it and confidence where it matters.
The Full Context Nightmare That Cost Us 3 Hours of CI Time
- Never use @SpringBootTest for unit-level controller tests
- Slice annotations are not optional — they are mandatory for any non-trivial Spring Boot app
- Mock all external dependencies in web layer tests to avoid cascading failures
mockMvc.perform(post("/api/v1/billing/subscriptions").contentType(MediaType.APPLICATION_JSON).content("{\"planId\":\"plan_premium\"}"))System.out.println(result.getResponse().getContentAsString())| File | Command / Code | Purpose |
|---|---|---|
| BillingControllerTest.java | @WebMvcTest(BillingController.class) | Setting Up @WebMvcTest |
| BillingControllerSecurityTest.java | @WebMvcTest(BillingController.class) | What the Official Docs Won't Tell You |
| MockMvcRequestHelpers.java | public class MockMvcRequestHelpers { | MockMvc Request Builders |
| ResponseAssertionsTest.java | @Test | Response Assertions with jsonPath and Hamcrest Matchers |
| ExceptionHandlingTest.java | @Test | Testing Exception Handling and Validation Errors |
| AsyncRequestTest.java | @Test | Advanced MockMvc |
| SubscriptionRepositoryTest.java | @DataJpaTest | Slice Annotations Beyond @WebMvcTest |
| BillingControllerIntegrationTest.java | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) | Integration Testing with @SpringBootTest and Testcontainers |
Key takeaways
Interview Questions on This Topic
Explain how @WebMvcTest works under the hood. What beans does it load?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Spring Boot. Mark it forged?
7 min read · try the examples if you haven't