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.
  • @SpringJUnitConfig carrega um contexto em cache; qualquer diferença de configuração, perfis, propriedades ou substituições cria outro, então padronize as entradas.
  • Testes @Transactional sofrem rollback automaticamente; @Sql popula dados; faça flush e clear antes de verificar escritas JPA.
  • @MockitoBean, @MockitoSpyBean e @TestBean (6.2) substituem beans nas fronteiras e fazem parte da chave de cache.
  • MockMvc e MockMvcTester exercitam mapeamentos, conversores e advices reais sem servidor; standaloneSetup isola um único controller.
  • MockRestServiceServer cobre o HTTP de saída, e @DynamicPropertySource com 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.