@SpringBootTest vs @WebMvcTest: Choosing the Right Test Slice

@SpringBootTest vs @WebMvcTest: Choosing the Right Test Slice

@SpringBootTest loads your entire application context — great for integration tests, slow for unit-level controller tests. @WebMvcTest loads only the web layer. Choosing wrong costs you seconds per test and misleading failure signals.

Spring Boot gives you two primary annotations for testing the web layer: @SpringBootTest and @WebMvcTest. Both spin up a Spring context. Neither is universally better. The difference is scope — and scope determines speed, isolation, and what you can actually test.

Get this wrong and you'll have a test suite that takes 4 minutes to run because someone used @SpringBootTest for every controller test. Get it right and your CI feedback loop drops to under 30 seconds.

What @SpringBootTest Actually Does

@SpringBootTest bootstraps the full ApplicationContext, exactly as it would start in production. Every @Component, @Service, @Repository, @Configuration, and auto-configuration class is loaded.

@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerFullContextTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void createOrder_persistsToDatabase() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"productId": "abc-123", "quantity": 2}
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.orderId").exists());

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

Because everything is loaded, @SpringBootTest can test the full request-to-database pipeline. That's exactly what makes it slow — and exactly when it's the right tool.

By default, @SpringBootTest does not start an embedded server. Add webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT if you need the actual HTTP layer:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderControllerHttpTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void createOrder_returns201() {
        var request = new OrderRequest("abc-123", 2);
        var response = restTemplate.postForEntity("/orders", request, OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().orderId()).isNotNull();
    }
}

Use RANDOM_PORT when you need cookie handling, redirects, or HTTP-level behavior that MockMvc abstracts away.

What @WebMvcTest Actually Does

@WebMvcTest is a test slice annotation. It loads only the components relevant to the MVC layer:

  • @Controller, @RestController, @ControllerAdvice
  • @JsonComponent, Filter, WebMvcConfigurer
  • MockMvc (auto-configured)

Everything else — services, repositories, database connections — is not loaded. You provide mocks for anything the controller depends on.

@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void createOrder_delegatesToService() throws Exception {
        var expectedOrder = new Order("order-1", "abc-123", 2);
        when(orderService.createOrder("abc-123", 2)).thenReturn(expectedOrder);

        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"productId": "abc-123", "quantity": 2}
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.orderId").value("order-1"));

        verify(orderService).createOrder("abc-123", 2);
    }
}

The slice loads fast because it skips all the infrastructure setup. On a cold JVM, @WebMvcTest starts in roughly 1–2 seconds. @SpringBootTest with JPA, Redis, and Kafka auto-configuration starts in 8–15 seconds.

Performance: Numbers That Matter

Here's what context loading actually costs on a typical mid-size application (Spring Boot 3.x, JPA + Redis + Kafka):

Annotation Context load time What's loaded
@WebMvcTest ~1.5s Web layer only
@DataJpaTest ~2s JPA layer only
@SpringBootTest (no server) ~8s Full context
@SpringBootTest (RANDOM_PORT) ~10s Full context + server

Spring caches application contexts between tests that use identical configurations. If you run 50 @WebMvcTest tests for different controllers, Spring reuses the same context for all of them — you pay the 1.5s startup cost once, not 50 times.

But if you mix @WebMvcTest(OrderController.class) and @WebMvcTest(UserController.class) with @MockBean configurations that differ between test classes, Spring creates separate contexts. Keep mock configurations consistent across test classes to maximize context reuse.

The Decision Framework

Use @WebMvcTest when:

  • You're testing controller logic: request mapping, input validation, response serialization, error handling
  • The controller delegates to a service and you want to verify that delegation
  • You want fast feedback on the web layer in isolation
  • You're testing @ControllerAdvice exception handling

Use @SpringBootTest when:

  • You're writing an integration test that verifies multiple layers working together
  • You need to assert that data actually persists to the database
  • You're testing application startup, auto-configuration, or bean wiring
  • You need real HTTP behavior (cookies, redirects, HTTPS)

The wrong use of @SpringBootTest:

// Don't do this — you don't need the full context to test input validation
@SpringBootTest
@AutoConfigureMockMvc
class OrderValidationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void createOrder_rejectsMissingProductId() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"quantity": 2}"""))
            .andExpect(status().isBadRequest());
    }
}

This works, but it boots the full context just to test that @NotNull on productId returns a 400. A @WebMvcTest slice does the same job in a fifth of the time.

Handling Dependencies in @WebMvcTest

The most common friction with @WebMvcTest is that Spring won't find beans your controller needs. If OrderController depends on OrderService, and OrderService isn't in the web layer, the test will fail to load.

The fix is always @MockBean:

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @MockBean
    private InventoryClient inventoryClient; // Feign client, also not in web layer
}

If your controller uses Spring Security, @WebMvcTest loads it by default. For tests that aren't about security, add @WithMockUser or configure @TestConfiguration to bypass auth:

@WebMvcTest(OrderController.class)
@Import(TestSecurityConfig.class)
class OrderControllerTest {
    // ...
}

@TestConfiguration
class TestSecurityConfig {

    @Bean
    SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

Testing Validation Errors

@WebMvcTest is ideal for testing that your validation annotations produce the right responses:

@WebMvcTest(OrderController.class)
class OrderValidationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void createOrder_rejectsMissingProductId() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"quantity": 2}"""))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].field").value("productId"))
            .andExpect(jsonPath("$.errors[0].message").value("must not be blank"));
    }

    @Test
    void createOrder_rejectsNegativeQuantity() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"productId": "abc-123", "quantity": -1}"""))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].field").value("quantity"));
    }
}

These tests execute in milliseconds after context startup. Running 20 of them costs the same as running 1.

@SpringBootTest with @MockBean

You can use @MockBean in @SpringBootTest too — but be aware of the context cache impact. Every unique combination of @MockBean declarations creates a separate application context. If you mock different beans in different test classes, Spring cannot reuse the context.

// These two test classes will NOT share a context because their @MockBean sets differ
@SpringBootTest
class TestClassA {
    @MockBean EmailService emailService;
}

@SpringBootTest
class TestClassB {
    @MockBean SmsService smsService;
}

For integration tests where you need most of the real context but want to stub one external service, define a single base test class:

@SpringBootTest
abstract class IntegrationTestBase {

    @MockBean
    protected ExternalPaymentGateway paymentGateway;

    @MockBean
    protected EmailService emailService;
}

class OrderIntegrationTest extends IntegrationTestBase {
    // inherits the same mock configuration — context is reused
}

class UserIntegrationTest extends IntegrationTestBase {
    // same context reused
}

Combining Both Effectively

A well-structured test suite uses both annotations at different levels:

src/test/java/
  controller/
    OrderControllerTest.java          # @WebMvcTest — fast, isolated
    UserControllerTest.java           # @WebMvcTest — fast, isolated
  service/
    OrderServiceTest.java             # Plain JUnit, no Spring context
  repository/
    OrderRepositoryTest.java          # @DataJpaTest — DB slice
  integration/
    OrderFlowIntegrationTest.java     # @SpringBootTest — full pipeline

With this structure, the controller/ and service/ tests run in seconds. The integration/ tests run less frequently (on push, not on every local build), and they test the wiring rather than the logic.

The rule is simple: test the logic with the smallest context that covers it. Only escalate to @SpringBootTest when you need to verify that the pieces fit together.

Read more

Start now free