Spring Boot e Spring Cloud

Testes em Spring Boot 3: slices, MockMvc, Testcontainers e cache de contexto

Intermediário8 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

Você vai aprender a testar um serviço Spring Boot 3 no nível certo para cada aspecto: testes unitários simples para a lógica, @WebMvcTest com MockMvc para os controllers, @DataJpaTest contra um banco de dados real no Testcontainers para os repositórios, @SpringBootTest para as verificações ponta a ponta, e as regras de cache de contexto e de substituição de beans que mantêm a suíte inteira rápida e confiável.

O que você vai aprender

  • Escolher entre um teste unitário, um slice de teste e um @SpringBootTest completo para um trecho de código específico
  • Testar controllers, validação e respostas de erro com @WebMvcTest e MockMvc
  • Rodar testes de repositório contra um banco de dados real com @DataJpaTest e o @ServiceConnection do Testcontainers
  • Substituir colaboradores com @MockitoBean e entender como as substituições de beans afetam o cache de contexto
  • Escrever testes ponta a ponta com @SpringBootTest, uma porta aleatória e um cliente HTTP real
  • Manter rápida uma suíte de testes em crescimento controlando o número de contextos de aplicação distintos

Um serviço Spring Boot tem vários tipos de código, cada um com seu próprio jeito de falhar: regras de negócio que podem estar erradas, controllers que podem mapear errado o JSON ou os status codes, queries que podem ser inválidas no banco de dados real e uma configuração que pode injetar o bean errado. Um único tipo de teste não dá conta de tudo isso. Subir a aplicação inteira para cada verificação deixa a suíte lenta e as falhas vagas; mockar tudo deixa a suíte rápida e sem sentido. Esta página apresenta uma abordagem em camadas no Spring Boot 3 com JUnit 5 e as ferramentas que o spring-boot-starter-test traz, e explica a mecânica do cache de contexto, que decide se uma suíte de quinhentos testes roda em vinte segundos ou em dez minutos.

O que o starter de testes oferece

O spring-boot-starter-test reúne JUnit Jupiter, AssertJ, Mockito, Hamcrest, JSONassert, JsonPath, Awaitility e o suporte de testes do próprio Spring (spring-test, spring-boot-test, spring-boot-test-autoconfigure). O starter não inclui mais o engine vintage do JUnit 4 desde o Boot 2.4; os testes usam o @Test de org.junit.jupiter.api, e o antigo @RunWith(SpringRunner.class) foi substituído pela extensão que o @SpringBootTest e os slices já declaram.

Os níveis, do mais rápido e focado ao mais lento e abrangente:

NívelAnotaçãoSobeUse para
UnitárionenhumanadaLógica de domínio, mappers, cálculos
Slice web@WebMvcTestinfraestrutura MVC + controllers selecionadosMapeamento de requisições, JSON, validação, respostas de erro, regras de segurança
Slice de dados@DataJpaTestJPA, repositórios, um banco de dadosQueries, mapeamentos, migrations
Outros slices@JsonTest, @RestClientTest, @DataJdbcTest, @WebFluxTest…um aspecto cadaSerialização, clientes HTTP
Contexto completo@SpringBootTesttudo, opcionalmente um servidor realLigação dos beans, fluxos ponta a ponta, configuração

A regra geral: leve cada verificação ao nível mais baixo capaz de prová-la. Uma regra de arredondamento não precisa do Spring; um status code não precisa de banco de dados; uma query JPQL precisa de banco de dados, mas não do Tomcat.

Testes unitários: sem precisar de contêiner

Como as aplicações Boot usam injeção por construtor, a maioria das classes pode ser instanciada diretamente:

class PricingServiceTest {

    private final TaxRates rates = mock(TaxRates.class);
    private final PricingService pricing = new PricingService(rates);

    @Test
    void appliesReducedRateToBooks() {
        when(rates.rateFor(Category.BOOKS)).thenReturn(new BigDecimal("0.055"));

        Money total = pricing.priceWithTax(new Item("ISBN-1", Category.BOOKS, Money.of("20.00")));

        assertThat(total).isEqualTo(Money.of("21.10"));
        verifyNoMoreInteractions(rates);
    }
}

Nada aqui é específico do Spring, e a ideia é justamente essa. Se uma classe é difícil de construir num teste, a causa costuma ser injeção por campo ou uma dependência do ApplicationContext; corrija o design em vez de apelar para o @SpringBootTest. O @ExtendWith(MockitoExtension.class) do Mockito, com @Mock e @InjectMocks, elimina o boilerplate quando há vários colaboradores.

Controllers com @WebMvcTest

O @WebMvcTest(OrderController.class) monta um contexto que contém só a camada web e o controller indicado, autoconfigura o MockMvc e espera que você forneça as dependências do controller:

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService orders;

    @Test
    void returnsOrderAsJson() throws Exception {
        when(orders.get(42L)).thenReturn(sampleOrder(42L));

        mvc.perform(get("/api/orders/42").accept(MediaType.APPLICATION_JSON))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.id").value(42))
           .andExpect(jsonPath("$.lines", hasSize(2)));
    }

    @Test
    void rejectsInvalidBodyWithProblemDetail() throws Exception {
        mvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"customerId": null, "lines": []}
                        """))
           .andExpect(status().isBadRequest())
           .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
           .andExpect(jsonPath("$.errors[*].field", containsInAnyOrder("customerId", "lines")));
        verifyNoInteractions(orders);
    }

    @Test
    void mapsDomainExceptionTo404() throws Exception {
        when(orders.get(7L)).thenThrow(new OrderNotFoundException(7L));

        mvc.perform(get("/api/orders/7"))
           .andExpect(status().isNotFound())
           .andExpect(jsonPath("$.title").value("Order not found"));
    }
}

O MockMvc aciona o DispatcherServlet diretamente, sem socket, então o teste exercita a resolução de argumentos, a validação, a conversão de mensagens, o @RestControllerAdvice e os filtros — exatamente o que testes unitários da classe do controller deixariam passar. A partir do Boot 3.4 (Spring Framework 6.2), o MockMvcTester oferece o mesmo no estilo AssertJ: assertThat(mvc.get().uri("/api/orders/42")).hasStatusOk().bodyJson().extractingPath("$.id").isEqualTo(42).

Com o Spring Security no classpath, o slice inclui a configuração de segurança, então requisições não autenticadas recebem 401, a menos que você adicione @WithMockUser do spring-security-test ou anexe um post-processor jwt(). Isso é uma vantagem, porque permite testar as regras de autorização endpoint por endpoint sem um servidor de tokens.

Repositórios com @DataJpaTest e Testcontainers

O @DataJpaTest configura o JPA, os repositórios do Spring Data e um TestEntityManager, e torna cada teste transacional com rollback no final, para que um teste não veja os dados do outro. Por padrão, ele troca o DataSource por um banco embarcado, o que é prático e enganoso; aponte-o, em vez disso, para o engine que você usa em produção:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired OrderRepository orders;
    @Autowired TestEntityManager em;

    @Test
    void findWithLinesFetchesLinesInOneQuery() {
        Order order = em.persistFlushFind(orderWithLines(3));
        em.clear();                                  // detach so the next load hits the database

        Optional<Order> loaded = orders.findWithLines(order.getId());

        assertThat(loaded).isPresent();
        assertThat(loaded.get().getLines()).hasSize(3);   // no LazyInitializationException: fetch join worked
    }

    @Test
    void expireStaleOrdersOnlyTouchesNewOrders() {
        em.persist(order(OrderStatus.NEW, daysAgo(10)));
        em.persist(order(OrderStatus.SHIPPED, daysAgo(10)));
        em.flush();

        int updated = orders.expireStaleOrders(OrderStatus.EXPIRED, Instant.now().minus(7, DAYS));

        assertThat(updated).isEqualTo(1);
    }
}

O @ServiceConnection (Boot 3.1+) lê o host, a porta e as credenciais do contêiner e configura o DataSource a partir deles, substituindo o antigo método @DynamicPropertySource, que definia spring.datasource.url na mão. O Flyway roda contra o contêiner como rodaria em produção, então o teste também valida as migrations. Um contêiner static sobe uma vez por classe de teste; para compartilhar um só por toda a suíte, declare-o numa classe @TestConfiguration importada por todos os testes, ou use o recurso de reutilização do Testcontainers.

Como o slice faz rollback após cada teste, o código que depende do que acontece no commit (@TransactionalEventListener, alocação via SEQUENCE, triggers do banco) não vai disparar. Anote um teste específico com @Transactional(propagation = NOT_SUPPORTED) ou @Commit quando for justamente esse comportamento que você está testando, e faça a limpeza explicitamente.

Ponta a ponta com @SpringBootTest

Quando a pergunta é “tudo funciona junto?”, suba tudo:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderFlowTest {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired TestRestTemplate http;              // pre-configured with the random port
    @MockitoBean PaymentGateway gateway;           // the one dependency that must not be real

    @Test
    void placingAnOrderReturns201AndPersistsIt() {
        when(gateway.charge(any())).thenReturn(Payment.approved("txn-1"));

        ResponseEntity<OrderResponse> response = http.postForEntity("/api/orders",
                new CreateOrderRequest(1L, List.of(new OrderLine("ABC-12345", 2))), OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).hasPath("/api/orders/" + response.getBody().id());

        ResponseEntity<OrderResponse> fetched = http.getForEntity(response.getHeaders().getLocation(), OrderResponse.class);
        assertThat(fetched.getBody().status()).isEqualTo("PAID");
    }
}

O RANDOM_PORT sobe o servidor embarcado numa porta livre e disponibiliza o TestRestTemplate (ou o WebTestClient, se o WebFlux estiver no classpath) com a URL base já configurada. O ambiente padrão, MOCK, sobe o contexto sem servidor e permite usar o MockMvc com @AutoConfigureMockMvc, o que é mais rápido e suficiente quando a camada de rede não é o alvo do teste.

O @SpringBootTest encontra a classe @SpringBootConfiguration subindo pela árvore de pacotes a partir do teste, e é por isso que a classe da aplicação fica no pacote raiz. As propriedades do teste vão em properties = "..." na anotação, num src/test/resources/application-test.yml ativado com @ActiveProfiles("test"), ou em @TestPropertySource; as duas primeiras opções são as que menos surpreendem.

Mantenha poucos testes desse tipo, com escopo amplo. Um teste que cobre um fluxo de usuário realista vale mais do que dez que repetem o que os slices já verificam.

Substituir beans e quanto isso custa

O @MockitoBean substitui (ou adiciona) um bean no contexto por um mock do Mockito que é resetado após cada método de teste; o @MockitoSpyBean envolve o bean real. Os dois vêm do Spring Framework 6.2 e substituem os @MockBean/@SpyBean do Boot, depreciados na 3.4. Para algo mais elaborado que um mock, uma classe @TestConfiguration com métodos @Bean, importada com @Import ou declarada como classe estática aninhada no teste, fornece um fake escrito à mão e costuma ser mais clara do que fazer stub de uma dúzia de chamadas.

O preço da substituição é pago em inicializações de contexto. O Spring guarda em cache os contextos de aplicação entre classes de teste, usando a configuração como chave: as classes de configuração, os perfis ativos, as propriedades, o conjunto de beans substituídos, o ambiente web e mais. Duas classes de teste com chaves idênticas compartilham um contexto; qualquer diferença significa outra inicialização completa. Uma suíte em que cada classe declara seus próprios campos @MockitoBean cria quase um contexto por classe e passa a maior parte do tempo subindo o Spring.

Hábitos que mantêm esse número baixo:

  • Defina mocks e fakes compartilhados uma única vez, numa classe base de teste ou numa @TestConfiguration comum, e reaproveite o mesmo conjunto de anotações entre as classes.
  • Evite @DirtiesContext, a não ser quando um teste realmente corrompe um estado compartilhado; ele descarta o contexto.
  • Coloque as propriedades de teste num único perfil em vez de em atributos properties por classe.
  • Use slices; um contexto @WebMvcTest sobe numa fração do tempo do contexto completo e é cacheado separadamente.

Para ver o que está acontecendo, conte os banners do Boot (ou as linhas “Started … in x seconds”) no log dos testes; logging.level.org.springframework.test.context.cache=DEBUG mostra os hits e misses do cache.

Outros slices que vale conhecer

O @JsonTest monta só a configuração do Jackson e fornece um JacksonTester<T> para fazer o round-trip de um DTO, útil para formatos de data e tratamento de null. O @RestClientTest(InventoryClient.class) configura um MockRestServiceServer ligado ao RestClient.Builder autoconfigurado, para que você possa verificar as requisições de saída e programar as respostas sem rede. @DataJdbcTest, @DataMongoTest, @DataRedisTest e @WebFluxTest seguem o mesmo padrão. Todo slice aceita @Import para beans extras e excludeAutoConfiguration para enxugar o que ele carrega.

Para jobs do Spring Batch, o @SpringBatchTest adiciona o JobLauncherTestUtils; para trabalho agendado ou assíncrono, o await().atMost(...).untilAsserted(...) do Awaitility substitui o Thread.sleep nas asserções.

Erros comuns

@SpringBootTest para tudo. Suítes lentas, falhas vagas e testes que passam porque algum mock em algum lugar retornou null. Comece no nível unitário e só suba quando a verificação exigir mais.

Mockar o repositório num teste de service e chamar isso de teste de integração. Isso não prova nada sobre a query. O comportamento de um repositório exige @DataJpaTest com um engine real.

H2 nos testes, PostgreSQL em produção. Queries nativas, on conflict, colunas JSON e o comportamento de sequences são diferentes. O Testcontainers acaba com a desculpa.

Esquecer que o slice não inclui tudo. Um @WebMvcTest não vai pegar um converter @Component nem um @Service de que o controller precisa; a mensagem de erro indica o bean que falta. Adicione-o com @Import ou com um mock.

Fazer asserções sobre o getMessage() de exceções ou sobre a saída de log. Os dois mudam com as versões das bibliotecas. Verifique status codes, corpos de resposta e o estado persistido.

Um conjunto diferente de @MockitoBean em cada classe. Cada combinação é um novo contexto. Consolide.

Testes que dependem da ordem ou de dados que sobraram. O slice de dados faz rollback, mas o @SpringBootTest não, a menos que você adicione @Transactional, e os bancos do Testcontainers persistem durante toda a classe. Limpe o estado explicitamente, ou faça cada teste criar o que precisa com chaves únicas.

Principais pontos

  • Teste cada aspecto no nível mais baixo capaz de prová-lo: testes unitários simples para a lógica, slices para as camadas web e de dados, e alguns fluxos @SpringBootTest para a ligação dos beans.
  • @WebMvcTest com MockMvc cobre roteamento, JSON, validação, respostas de erro e regras de segurança sem servidor nem banco de dados.
  • @DataJpaTest com @AutoConfigureTestDatabase(replace = NONE) e um @ServiceConnection do Testcontainers testa queries e migrations contra o engine de banco de dados real.
  • @MockitoBean substitui colaboradores no contexto; cada conjunto distinto de substituições, propriedades ou perfis cria mais um contexto em cache, então mantenha as configurações uniformes.
  • @SpringBootTest(webEnvironment = RANDOM_PORT) com TestRestTemplate oferece verificações ponta a ponta de verdade; use-as com moderação e faça cada uma valer a pena.
  • Acompanhe as inicializações de contexto no log: é o número de contextos, e não o número de testes, que determina o tempo da suíte.

Fontes e leituras complementares

Perguntas frequentes

Por que meus testes de Spring Boot são tão lentos?
Quase sempre porque a suíte sobe muitos contextos de aplicação distintos. O Spring guarda um contexto em cache e o reaproveita em todas as classes de teste com a mesma configuração, mas qualquer diferença (outro conjunto de campos @MockitoBean, outras propriedades, outro perfil ativo, @DirtiesContext) cria um novo, e cada inicialização custa segundos. Concentre os mocks numa configuração de teste compartilhada, evite propriedades por classe e confira no log quantas vezes o banner do Boot aparece.
O que substituiu o @MockBean no Spring Boot 3.4?
@MockitoBean e @MockitoSpyBean, do Spring Framework 6.2, no pacote org.springframework.test.context.bean.override.mockito. O Boot depreciou seus próprios @MockBean e @SpyBean na 3.4 em favor deles. Na maioria dos usos o comportamento é o mesmo: o bean do contexto é substituído por um mock do Mockito que é resetado após cada teste, e a substituição conta como uma diferença para o cache de contexto.
Os testes de repositório devem usar H2 ou o banco de dados real?
O engine de banco de dados real, via Testcontainers. O H2 aceita SQL que o PostgreSQL ou o MySQL rejeitam e não tem os tipos e funções deles, então uma suíte verde no H2 pode esconder uma migration ou uma query nativa que falha. Por padrão, o @DataJpaTest troca seu DataSource por um embarcado; desative isso com @AutoConfigureTestDatabase(replace = NONE) e aponte-o para um contêiner com @ServiceConnection.
O @WebMvcTest carrega meus services e repositórios?
Não. Ele cria um contexto só com a camada web: controllers, controller advice, filtros, converters, beans WebMvcConfigurer e o Spring Security, se estiver presente. Todo service de que um controller depende precisa ser fornecido, normalmente como @MockitoBean; se faltar, o contexto não sobe e dá um erro de dependência não satisfeita, que é exatamente o slice fazendo o seu trabalho.
SpringTestando aplicações Spring: testes unitários, TestContext e MockMvcTeste código Spring no nível certo: testes unitários puros, contextos em cache com SpringExtension, rollback transacional, @MockitoBean e MockMvc.Intermediário6 min de leituraTestesTestes unitários em Java com JUnit 5 e Mockito 5Testes unitários que sobrevivem à refatoração: ciclo de vida do JUnit 5, asserções, testes parametrizados e aninhados, AssertJ, test doubles e Mockito.Iniciante8 min de leituraTestesTestes de integração com dependências reais: Testcontainers, WireMock e contratosTeste por fronteiras reais: bancos em contêineres, isolamento de estado, HTTP simulado com WireMock, testes de broker e contratos orientados ao consumidor.Intermediário10 min de leituraAPIs REST com Spring Boot 3: controllers, DTOs, códigos de status e clientesConstrua uma API REST JSON com Spring Boot 3: controllers orientados a recursos, records como DTOs, códigos de status corretos, paginação, CORS e RestClient.Intermediário9 min de leituraSpring Data JPA com Spring Boot: repositórios, consultas, transações e migraçõesSpring Data JPA no Spring Boot 3 e Hibernate 6: repositórios, métodos derivados e @Query, paginação, projeções, transações, N+1 e Flyway.Intermediário8 min de leituraValidação e tratamento de erros no Spring Boot 3: Bean Validation e Problem DetailsValide corpos e parâmetros com Jakarta Bean Validation e transforme cada falha em uma resposta Problem Details RFC 9457 consistente.Intermediário7 min de leitura