Spring Framework
Testando aplicações Spring: testes unitários, TestContext e MockMvc
Intermediário6 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
A maior parte do código Spring deveria ser testada sem o Spring, e o restante com um contexto cuidadosamente mantido em cache. Este guia mostra como a injeção via construtor mantém os testes unitários rápidos, como o framework Spring TestContext carrega e reaproveita contextos no JUnit 5, como testar o acesso a dados com rollback transacional e @Sql, como substituir beans por mocks e como exercitar controllers com MockMvc e com o MockMvcTester, baseado em AssertJ, sem subir um servidor.
O que você vai aprender
- Decidir quais testes precisam de um contexto Spring e escrever os demais como testes JUnit 5 comuns
- Carregar um contexto de teste com @SpringJUnitConfig, perfis e propriedades de teste, mantendo-o em cache entre classes
- Testar repositories dentro de transações revertidas, com dados carregados via @Sql
- Substituir colaboradores com @MockitoBean e @TestBean sem quebrar o cache de contextos mais do que o necessário
- Testar controllers MVC e endpoints REST com MockMvc e MockMvcTester, incluindo validação e corpos de erro
O melhor teste de um service Spring é aquele que nunca carrega o Spring. Com injeção via construtor, um service é um objeto comum que você monta com fakes em poucas linhas, e esses testes rodam em milissegundos. O suporte a testes do Spring serve para as partes que realmente dependem do framework — wiring, transações, acesso a dados, mapeamento de requisições, segurança — e seu problema central é o custo: um ApplicationContext leva segundos para ser construído, então todo o design do framework TestContext gira em torno de construir o mínimo possível de contextos e reaproveitá-los. Este guia cobre o Spring Framework 6.x com JUnit 5 e o módulo spring-test; os test slices do Spring Boot são tratados na trilha de Spring Boot.
Nível um: sem container
class OrderServiceTest {
private final OrderRepository repository = mock(OrderRepository.class);
private final Mailer mailer = mock(Mailer.class);
private final OrderService service = new OrderService(repository, mailer);
@Test
void confirmSendsMailToCustomer() {
when(repository.find(42L)).thenReturn(new Order(42L, "[email protected]"));
service.confirm(42L);
verify(mailer).send("[email protected]", "Order 42 confirmed");
}
}
Nada aqui é específico do Spring, e a ideia é justamente essa. Se uma classe não pode ser testada assim — porque usa injeção via campo, chama getBean ou lê um holder estático de ApplicationContext —, corrija a classe antes de apelar para um container. O que testes puros não conseguem verificar é o comportamento que o Spring adiciona: que @Transactional faz rollback, que @Cacheable devolve o valor em cache, que uma URL é mapeada para o método certo, que a regra de segurança nega o acesso. Para isso é preciso um contexto.
O framework TestContext
O spring-test se integra ao JUnit 5 por meio da SpringExtension. A anotação composta @SpringJUnitConfig combina a extensão com @ContextConfiguration:
@SpringJUnitConfig(classes = { PersistenceConfig.class, ServiceConfig.class })
@ActiveProfiles("test")
@TestPropertySource(properties = "app.mail.enabled=false")
class CatalogServiceIntegrationTest {
@Autowired CatalogService catalog; // injeção via campo é aceitável em testes
@Test
void repricesAndRecordsHistory(@Autowired PriceHistoryRepository history) { // injeção por parâmetro também funciona
catalog.reprice("SKU-1", Money.of(12_50));
assertThat(history.forSku("SKU-1")).hasSize(1);
}
}
Use uma configuração de teste dedicada: uma classe @Configuration em src/test/java que importa a configuração de produção e substitui as partes que precisam de um dublê de teste, como um Clock ou um gateway de saída. Mantenha pequeno o número dessas configurações, pelo motivo explicado a seguir.
Cache de contextos
O framework guarda em cache cada contexto que constrói, usando como chave o conjunto completo de entradas: classes ou locais de configuração, perfis ativos, fontes de propriedades de teste, inicializadores de contexto, customizers (incluindo substituições de beans) e o loader. Duas classes de teste com entradas idênticas compartilham um único contexto durante toda a execução da JVM; qualquer diferença cria outro. Uma suíte com cinquenta classes de teste e três configurações constrói três contextos; a mesma suíte com um @TestPropertySource diferente em cada classe constrói cinquenta.
@DirtiesContext marca um contexto como inutilizável após o teste e força sua reconstrução. Às vezes é necessário — um teste que altera o estado de um singleton ou para um bean —, mas com mais frequência é sintoma de um teste que deveria restaurar o que mexeu. Prefira restaurar o estado em @AfterEach. A propriedade estática spring.test.context.cache.maxSize e a linha de log Started … context ajudam a ver quantos contextos uma execução cria.
Propriedades, perfis e valores dinâmicos
@ActiveProfiles seleciona beans condicionados a perfis, @TestPropertySource adiciona propriedades com a maior precedência e @DynamicPropertySource fornece valores conhecidos apenas em tempo de execução — a porta de um container iniciado pelo Testcontainers, por exemplo:
@SpringJUnitConfig(PersistenceConfig.class)
@Testcontainers
class RepositoryContractTest {
@Container
static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("db.url", postgres::getJdbcUrl);
registry.add("db.user", postgres::getUsername);
registry.add("db.password", postgres::getPassword);
}
…
}
Testar contra o banco de dados real em vez do H2 pega diferenças de dialeto — sequences, RETURNING, colunas JSON, diferenciação entre maiúsculas e minúsculas — que um substituto em memória esconde. O container é compartilhado por todos os testes da classe porque o campo é estático.
Testes de acesso a dados
Adicione @Transactional a uma classe de teste e cada método roda em uma transação que sofre rollback no final, de modo que os testes não conseguem poluir os dados uns dos outros. Popule linhas com @Sql:
@SpringJUnitConfig(PersistenceConfig.class)
@Transactional
class JpaProductRepositoryTest {
@Autowired ProductRepository products;
@PersistenceContext EntityManager em;
@Test
@Sql("/sql/three-products.sql") // roda na mesma transação, antes do método
void findsBySku() {
assertThat(products.findBySku("SKU-2")).isPresent();
}
@Test
void savesNewProduct() {
Product saved = products.save(new Product("SKU-9", "Kettle", 2_999));
em.flush(); // força o INSERT para que as constraints sejam verificadas
em.clear(); // limpa o cache de primeiro nível para a próxima leitura ir ao SQL
assertThat(products.findBySku("SKU-9")).map(Product::getName).contains("Kettle");
}
@Test
@Commit // mantém os dados — raramente é o que se quer
void seedsReferenceData() { … }
}
@Sql também aceita executionPhase = AFTER_TEST_METHOD para scripts de limpeza e @SqlConfig para separadores e tratamento de erros. TestTransaction.flagForCommit() e TestTransaction.end() dão controle programático quando um teste precisa observar dados commitados — para verificar um @TransactionalEventListener, por exemplo, que por definição nunca dispara em um teste com rollback.
A linha do flush() não é cerimônia opcional. Com JPA, o INSERT do save é adiado até o flush; se o teste só faz asserções sobre a entidade gerenciada em memória, ele passa mesmo quando o banco rejeitaria a linha.
Substituindo beans
O Spring Framework 6.2 trouxe a substituição de beans para o spring-test, que assim deixou de depender do Spring Boot para isso:
@SpringJUnitConfig(ServiceConfig.class)
class CheckoutServiceTest {
@MockitoBean PaymentGateway gateway; // substitui o bean PaymentGateway por um mock do Mockito
@MockitoSpyBean OrderRepository orders; // envolve o bean real em um spy
@Autowired CheckoutService checkout;
@Test
void declinedPaymentLeavesOrderUnpaid() {
when(gateway.charge(any())).thenReturn(PaymentResult.declined("insufficient funds"));
checkout.pay(7L);
verify(orders, never()).markPaid(7L);
}
}
@TestBean é a variante sem Mockito: substitui um bean pelo valor de retorno de um método factory estático da classe de teste, o que é ideal para um fake escrito à mão com comportamento real. Os três criam um customizer de contexto, então uma classe de teste com @MockitoBean PaymentGateway recebe um contexto em cache diferente de outra sem ele. Declarar as substituições em uma classe base abstrata estendida pelos testes relacionados mantém a chave de cache idêntica entre eles.
Faça mock nas fronteiras — gateways de pagamento, e-mail, APIs remotas, o relógio — e mantenha os beans reais no meio. Um teste que mocka o repository abaixo de um service que, por sua vez, está mockado no teste do controller não verificou nada sobre o wiring.
Testando controllers com MockMvc
O MockMvc aciona o DispatcherServlet com objetos simulados de requisição e resposta de servlet. Construí-lo a partir de um WebApplicationContext exercita seus mapeamentos, conversores, @ControllerAdvice e interceptors reais:
@SpringJUnitWebConfig(WebConfig.class) // @SpringJUnitConfig + @WebAppConfiguration
class ProductControllerTest {
MockMvc mvc;
@MockitoBean ProductService products;
@BeforeEach
void setUp(WebApplicationContext wac) {
mvc = MockMvcBuilders.webAppContextSetup(wac).build();
}
@Test
void returnsProductAsJson() throws Exception {
when(products.find(1L)).thenReturn(Optional.of(new Product(1L, "Kettle", 2_999)));
mvc.perform(get("/api/products/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Kettle"))
.andExpect(jsonPath("$.priceCents").value(2999));
}
@Test
void rejectsInvalidBodyWithProblemDetail() throws Exception {
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "", "priceCents": -5}
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.errors[*].field").value(containsInAnyOrder("name", "priceCents")));
}
}
MockMvcBuilders.standaloneSetup(new ProductController(products)) dispensa totalmente o contexto e é a forma mais rápida de testar o mapeamento e o binding de um único controller; registre o advice com .setControllerAdvice(new ApiExceptionHandler()). A contrapartida é que o modo standalone usa a configuração MVC padrão, então conversores ou argument resolvers personalizados do seu WebMvcConfigurer ficam de fora, a menos que você os adicione manualmente.
MockMvcTester
O Spring Framework 6.2 adicionou o MockMvcTester, uma fachada nativa do AssertJ sobre o mesmo motor, que elimina a exceção checked e a sopa de imports estáticos:
MockMvcTester tester = MockMvcTester.from(wac);
assertThat(tester.get().uri("/api/products/{id}", 1).accept(MediaType.APPLICATION_JSON))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.bodyJson().extractingPath("$.name").isEqualTo("Kettle");
assertThat(tester.get().uri("/api/products/999"))
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON);
As exceções lançadas pelo controller são capturadas como parte do resultado em vez de se propagarem, então você pode verificar .hasFailed() e inspecionar a causa. As duas APIs funcionam; o tester é mais legível para testes novos e exige AssertJ no classpath de teste.
Com o Spring Security presente, adicione spring-security-test e aplique springSecurity() de SecurityMockMvcConfigurers ao builder; @WithMockUser e with(jwt()) passam então a permitir testar as regras de autorização sem precisar fazer login de verdade.
Testando HTTP de saída
O MockRestServiceServer se associa a um RestClient.Builder (ou a um RestTemplate) e devolve respostas predefinidas, para que uma classe gateway seja testada contra o JSON exato que o seu parceiro documenta:
RestClient.Builder builder = RestClient.builder().baseUrl("https://inventory.internal/api");
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
InventoryGateway gateway = new InventoryGateway(builder.build());
server.expect(requestTo("https://inventory.internal/api/stock/SKU-1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"sku": "SKU-1", "available": 3}
""", MediaType.APPLICATION_JSON));
assertThat(gateway.stockFor("SKU-1").available()).isEqualTo(3);
server.verify();
Erros comuns
Carregar um contexto para testar lógica. Se a asserção é sobre um cálculo ou um desvio condicional, um teste puro é mais rápido, mais claro e não quebra quando a configuração muda.
Um contexto diferente por classe de teste. Valores de @TestPropertySource por classe, @Imports avulsos e @MockitoBeans espalhados multiplicam os contextos. Consolide.
Fazer asserções sobre estado JPA sem flush. O teste passa; a produção falha na constraint. Faça flush e clear antes de ler de volta.
@DirtiesContext por hábito. Ele transforma um contexto em cache em um contexto reconstruído para cada classe que o usa. Em vez disso, restaure o estado.
Mockar tudo abaixo da classe sob teste em um teste com contexto. Você pagou por um contexto e não verificou nada do que ele oferece. Faça mock apenas nas fronteiras externas.
Testar a segurança chamando o service diretamente. @PreAuthorize é responsabilidade do proxy; uma chamada direta ao objeto mockado ou real sem contexto de segurança não prova nada. Use @WithMockUser contra o bean com proxy.
Confiar no H2 para uma aplicação PostgreSQL. Recursos de dialeto, comportamento de sequences e operadores JSON são diferentes. Use o banco real em um container nos testes de acesso a dados.
Principais pontos
- Classes com injeção via construtor são testadas como objetos comuns; reserve o contexto Spring para wiring, transações, acesso a dados, web e segurança.
@SpringJUnitConfigcarrega um contexto em cache; qualquer diferença de configuração, perfis, propriedades ou substituições cria outro, então padronize as entradas.- Testes
@Transactionalsofrem rollback automaticamente;@Sqlpopula dados; faça flush e clear antes de verificar escritas JPA. @MockitoBean,@MockitoSpyBeane@TestBean(6.2) substituem beans nas fronteiras e fazem parte da chave de cache.MockMvceMockMvcTesterexercitam mapeamentos, conversores e advices reais sem servidor;standaloneSetupisola um único controller.MockRestServiceServercobre o HTTP de saída, e@DynamicPropertySourcecom Testcontainers, os bancos de dados reais.
Fontes e leituras complementares
Perguntas frequentes
- Por que minha suíte de testes é lenta se cada teste é rápido?
- Quase sempre por excesso de contextos. Cada combinação distinta de classes de configuração, perfis ativos, propriedades de teste e substituições de beans cria e guarda em cache um ApplicationContext separado, e cada um custa alguns segundos. Padronize um número pequeno de configurações de teste, evite @DirtiesContext e coloque as declarações @MockitoBean em uma classe base ou configuração compartilhada para que as classes compartilhem o mesmo contexto. O Spring registra no log linhas “Started … context” que você pode contar.
- @Transactional em um teste realmente desfaz minhas alterações JPA?
- Sim — por padrão a transação sofre rollback após cada método de teste —, mas com uma sutileza: o Hibernate adia o SQL até o flush, e uma transação marcada para rollback pode nunca fazer flush, então violações de constraint e erros de mapeamento ficam escondidos. Chame entityManager.flush() (e clear()) no final do teste, ou logo após a escrita que você quer verificar, para que o SQL seja de fato executado.
- Qual a diferença entre @MockitoBean e o @MockBean do Spring Boot?
- Eles fazem o mesmo trabalho: substituir ou adicionar um bean no contexto de teste com um mock do Mockito. O @MockitoBean fica no módulo spring-test do Spring Framework 6.2 e funciona com qualquer teste Spring, enquanto o @MockBean veio do Spring Boot e foi descontinuado no Boot 3.4 em favor do @MockitoBean. Ambos geram uma chave de cache de contexto distinta para cada combinação de beans mockados.
- Devo usar MockMvc ou um servidor real com TestRestTemplate?
- O MockMvc executa o DispatcherServlet dentro do processo com objetos de requisição simulados: sem porta, sem parsing HTTP, rápido, e chegando ao controller pelos handler mappings, conversores e advices reais. Ele não consegue testar filtros de servlet registrados fora do Spring, a configuração do servidor embarcado nem detalhes do nível HTTP, como compressão. Use MockMvc para o comportamento dos controllers e poucos testes com servidor real para a superfície de deploy.