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,WebMvcConfigurere Jackson. Serviços@Service,@Repositoryou@Componentnormais 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.@MockitoBeancria um mock Mockito paraOrderServicee o registra no context. AssimOrderControllertem a dependência para ser inicializado.
Nota: A partir do Spring Boot 3.4,
@MockBeanestá marcado como deprecated. Você deve usar@MockitoBeando pacoteorg.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 umaOrderfixa quando chamado com o id1.perform(get(...))envia uma requisiçãoGET /api/orders/1através doDispatcherServlet. 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 porquerecordgeraequals()automaticamente. A requisição desserializada pelo controller será igual ao objetorequestno teste. Se estiver usando uma classe comum que não sobrescreveequals(), useany(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
SecurityFilterChainque 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
@WebMvcTestse 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)comTestRestTemplateouWebTestClient. 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
contentTypeao enviar corpo. - Mantenha a classe
@SpringBootApplicationlimpa para que os test slices não carreguem configurações extras.