Testando controller com @WebMvcTest e MockMvc

1. Visão geral

Testar controller com @SpringBootTest carrega toda a aplicação, incluindo banco de dados, message broker e todos os outros beans. Essa abordagem é lenta e difícil para isolar erros. Spring Boot fornece @WebMvcTest para carregar apenas a camada web. Combinado com MockMvc, você pode enviar requisições simuladas sem precisar iniciar um servidor real.

Depois de ler este artigo, você saberá como testar um REST controller com @WebMvcTest. Especificamente, verificar status, headers e JSON retornado, testar validação, testar exception handlers, e evitar erros comuns.

2. Dependências

Você precisa de spring-boot-starter-web para o controller, spring-boot-starter-validation para Bean Validation, e spring-boot-starter-test para os testes:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-test já vem com JUnit 5, AssertJ, Mockito, JsonPath e Spring Test. Você não precisa adicionar mais nada.

3. Controller a ser testado

Vamos usar uma API simples de gerenciamento de pedidos. Primeiro, o model e o request:

public record Order(Long id, String customerName, BigDecimal total) {}

public record CreateOrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal total) {}

A camada service é uma interface. No teste, vamos fazer mock dela:

public interface OrderService {
    Order findById(Long id);
    Order create(CreateOrderRequest request);
}

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("Pedido não encontrado " + id);
    }
}

O controller tem dois endpoints:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }

    @PostMapping
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.id()))
                .body(created);
    }
}

Por fim, um @RestControllerAdvice para converter OrderNotFoundException em HTTP 404:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleNotFound(OrderNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

4. Primeiro teste com @WebMvcTest

4.1. Estrutura do teste

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OrderService orderService;
}

Cada parte funciona assim:

  • @WebMvcTest(OrderController.class) cria um Spring context contendo apenas beans relacionados a MVC. São eles: OrderController, qualquer @ControllerAdvice, Converter, Filter, WebMvcConfigurer e Jackson. Serviços @Service, @Repository ou @Component normais não são carregados.
  • MockMvc é auto-configurado, basta fazer inject.
  • ObjectMapper é o bean Jackson configurado pelo Spring Boot. Use-o para serializar requests para que o teste corresponda à configuração real.
  • @MockitoBean cria um mock Mockito para OrderService e o registra no context. Assim OrderController tem a dependência para ser inicializado.

Nota: A partir do Spring Boot 3.4, @MockBean está marcado como deprecated. Você deve usar @MockitoBean do pacote org.springframework.test.context.bean.override.mockito. Se estiver usando Spring Boot 3.3 ou anterior, use @MockBean. O funcionamento é similar.

4.2. Testar GET que retorna JSON

@Test
void shouldReturnOrderWhenFound() throws Exception {
    given(orderService.findById(1L))
            .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

    mockMvc.perform(get("/api/orders/{id}", 1L))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.customerName").value("Nguyen Van A"))
            .andExpect(jsonPath("$.total").value(250000));
}

Os imports estáticos necessários são:

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

Explicação passo a passo:

  • given(...).willReturn(...) configura o mock para retornar uma Order fixa quando chamado com o id 1.
  • perform(get(...)) envia uma requisição GET /api/orders/1 através do DispatcherServlet. A requisição não passa pela rede.
  • andDo(print()) imprime a requisição e resposta completas no console. Este passo é muito útil quando um teste falha.
  • jsonPath("$.customerName") consulta um field no corpo JSON.

A saída de print() terá a forma:

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"id":1,"customerName":"Nguyen Van A","total":250000}

5. Testar POST com corpo da requisição

5.1. Caso de sucesso

@Test
void shouldCreateOrder() throws Exception {
    var request = new CreateOrderRequest("Tran Thi B", new BigDecimal("99000"));
    given(orderService.create(request))
            .willReturn(new Order(10L, "Tran Thi B", new BigDecimal("99000")));

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/api/orders/10"))
            .andExpect(jsonPath("$.id").value(10));
}

Aqui há dois pontos importantes:

  • contentType(MediaType.APPLICATION_JSON) é obrigatório. Se faltar, Spring retorna 415 Unsupported Media Type.
  • given(orderService.create(request)) funciona porque record gera equals() automaticamente. A requisição desserializada pelo controller será igual ao objeto request no teste. Se estiver usando uma classe comum que não sobrescreve equals(), use any(CreateOrderRequest.class) em seu lugar.

5.2. Testar validação

@WebMvcTest carrega a configuração de validação. Portanto @Valid funciona como em produção:

@Test
void shouldReturnBadRequestWhenCustomerNameIsBlank() throws Exception {
    String invalidJson = """
            {"customerName": "", "total": -5}
            """;

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(invalidJson))
            .andExpect(status().isBadRequest());

    verify(orderService, never()).create(any());
}

A linha verify(orderService, never()).create(any()) garante que o service não seja chamado quando os dados são inválidos. Esse é o comportamento que você quer proteger: validação bloqueia a requisição na camada web.

Este teste usa um text block JSON em vez de ObjectMapper. Essa forma permite enviar payloads que o record Java não consegue representar, por exemplo JSON com tipos de dados incorretos.

6. Testar exception handler

Como @WebMvcTest carrega os @RestControllerAdvice automaticamente, você pode testar o mapeamento de exceptions para status HTTP:

@Test
void shouldReturnNotFoundWhenOrderDoesNotExist() throws Exception {
    given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));

    mockMvc.perform(get("/api/orders/{id}", 99L))
            .andExpect(status().isNotFound())
            .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.detail").value("Pedido não encontrado 99"));
}

O corpo da resposta segue o padrão RFC 9457 (Problem Details):

{"type":"about:blank","title":"Not Found","status":404,"detail":"Pedido não encontrado 99","instance":"/api/orders/99"}

Esse é o grande diferencial de @WebMvcTest em relação ao teste unitário puro chamando diretamente orderController.getOrder(99L). Um teste unitário puro apenas vê a exception sendo lançada. Ele não consegue verificar o que o cliente realmente recebe em termos de status e corpo da resposta.

7. Escrever assertions com AssertJ usando MockMvcTester

A partir do Spring Boot 3.4 (Spring Framework 6.2), se AssertJ estiver no classpath, o Spring Boot auto-configura MockMvcTester. Este bean permite escrever assertions com AssertJ em vez de uma cadeia de andExpect:

@WebMvcTest(OrderController.class)
class OrderControllerAssertJTest {

    @Autowired
    private MockMvcTester mvc;

    @MockitoBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderWhenFound() {
        given(orderService.findById(1L))
                .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

        assertThat(mvc.get().uri("/api/orders/{id}", 1L))
                .hasStatusOk()
                .hasContentType(MediaType.APPLICATION_JSON)
                .bodyJson()
                .extractingPath("$.customerName")
                .isEqualTo("Nguyen Van A");
    }
}

Comparado com MockMvc, essa forma de escrever tem três vantagens:

  • Sem necessidade de throws Exception.
  • Não precisa memorizar muitos imports estáticos de MockMvcResultMatchers.
  • As mensagens de erro do AssertJ geralmente são mais legíveis.

As duas APIs têm capacidades equivalentes. Escolha uma e use consistentemente em seu projeto.

8. Limitar o que é carregado

8.1. Especificar o controller

Se escrever @WebMvcTest sem parâmetros, Spring carrega todos os controllers da aplicação. Isso significa que você precisa fazer mock da dependência de todos os controllers, até mesmo aqueles não relacionados ao teste. Por isso sempre especifique o controller a ser testado:

@WebMvcTest(OrderController.class)

8.2. Carregar beans específicos

Às vezes o controller usa um bean pequeno, sem efeitos colaterais, como um OrderMapper. Fazer mock deste bean não agrega valor. Você pode carregar o bean real usando @Import:

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

8.3. Quando há Spring Security

Se spring-boot-starter-security estiver no classpath, @WebMvcTest também carregará a security filter chain. Os testes acima retornarão 401 ou 403. Você precisa adicionar a dependência:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

Depois, simule um usuário e envie um CSRF token para requisições que modificam dados:

@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
    // ... stub orderService como em 5.1
    mockMvc.perform(post("/api/orders")
                    .with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("""
                            {"customerName": "Le Van C", "total": 120000}
                            """))
            .andExpect(status().isCreated());
}

Nota: A classe SecurityFilterChain que você define (marcada com @Configuration) não é carregada automaticamente em todas as versões. Se quiser testar as regras de autorização corretamente, adicione @Import(SecurityConfig.class).

9. Erros comuns

NoSuchBeanDefinitionException ao iniciar o teste. O controller depende de um bean que não é da camada web e você ainda não fez mock dele. Solução: adicione @MockitoBean para esse bean, ou faça @Import dele.

Annotations na classe @SpringBootApplication danificam o teste. Por exemplo, você coloca @EnableJpaRepositories ou @EnableScheduling diretamente na classe principal. @WebMvcTest ainda lê essa classe, então tentará criar repository e falhará por falta de DataSource. Solução: mova essas annotations para uma classe @Configuration separada.

Esquecer contentType. Resultado: 415 em vez do status esperado.

Verificar resultado com MockMvc mas a lógica está no service. @WebMvcTest deve apenas verificar a parte do trabalho da camada web:

  • roteamento,
  • serialização,
  • validação,
  • código de status,
  • mapeamento de exceptions.

A lógica de negócio deve ser testada separadamente no service usando um teste unitário puro.

Over-mocking. Se um teste precisa fazer stub de cinco ou seis methods, talvez o controller esteja fazendo muita coisa. Isso é um sinal para refatorar, não para escrever mais stubs.

Nota: Não use @WebMvcTest se quiser verificar um fluxo end-to-end através do banco de dados, ou se precisar testar o comportamento do servidor real (porta, HTTP/2, filtros do container). Nesse caso, use @SpringBootTest(webEnvironment = RANDOM_PORT) com TestRestTemplate ou WebTestClient. Se a aplicação usar WebFlux, o slice correspondente é @WebFluxTest, não @WebMvcTest.

10. Conclusão

@WebMvcTest ajuda você a testar controller rápido e focado. Ele carrega apenas a camada web, e as dependências de trás são substituídas por @MockitoBean. Combinado com MockMvc ou MockMvcTester, você verifica o que o cliente realmente vê: código de status, headers, JSON, validação e mapeamento de exceptions.

As regras principais a lembrar:

  • Sempre especifique o controller em @WebMvcTest.
  • Faça mock apenas do que o controller precisa.
  • Não esqueça contentType ao enviar corpo.
  • Mantenha a classe @SpringBootApplication limpa para que os test slices não carreguem configurações extras.
spring-boottestingmockmvcjunit5webmvctest