Estratégia de Testes
Testes são uma garantia de qualidade—escrever código sem testes é como dirigir sem cinto de segurança; cedo ou tarde, um acidente vai acontecer.
1. O Que Você Vai Aprender
- Testes de Integração
@SpringBootTeste Testes de Fatia@WebMvcTest - Mockito
@MockBean/@SpyBeanMockando Dependências - Testes da Camada de Dados
@DataJpaTeste Banco de Dados H2 Embutido - TestContainers: Testes de Integração Containerizados para MySQL e Redis
- Alice aumentou a cobertura de testes do processo principal de realização de pedidos do OrderFlow para mais de 80%
2. Uma História Real de um Engenheiro de Qualidade
(1) Dor: Corrigir um bug gera mais três
Alice modificou a lógica de cancelamento de pedidos, pensando que afetaria apenas a funcionalidade de cancelamento, mas acabou causando problemas na restauração de estoque, que por sua vez afetou as estatísticas de relatórios. Toda vez que ela altera o código, fica apreensiva porque não há testes automatizados para verificar regressões. Bob é frequentemente acordado no meio da noite por chamadas para correções de emergência, e a equipe gasta 30% do tempo a cada semana corrigindo bugs.
(2) Soluções com Testes Automatizados
Escreva bons testes para fornecer uma "rede de segurança" ao modificar o código:
@SpringBootTest
class OrderServiceTest {
@Test
void shouldCancelOrderAndRestoreStock() {
// Dado: produto com estoque 10, pedido com quantidade 3
// Quando: cancelar pedido
// Então: estoque restaurado para 13, status do pedido é CANCELLED
}
}
(3) Resultado
Alice escreveu 50 casos de teste para o processo principal do OrderFlow, alcançando 85% de cobertura de código. Toda vez que o código é modificado, os testes são executados automaticamente, e problemas de regressão são detectados em cinco minutos—assim Bob não precisa mais corrigir bugs no meio da noite.
3. A Pirâmide de Testes
(1) Estratégia de Testes em Três Camadas
graph LR
A["Testes Unitários<br/>70% - Rápidos, Isolados<br/>JUnit + Mockito"] --> B["Testes de Integração<br/>20% - Velocidade Média<br/>@SpringBootTest + H2"]
B --> C["Testes E2E<br/>10% - Lentos, Stack Completa<br/>TestContainers + REST Assured"]
| Nível | Porcentagem | Velocidade | Dependências | Conteúdo do Teste |
|---|---|---|---|---|
| Testes Unitários | 70% | Nível de milissegundo | Mock | Lógica de Negócio |
| Testes de Integração | 20% | Segundos | H2/TestContainers | Interação de Componentes |
| Testes E2E | 10% | Minutos | Ambiente Completo | Processo de Negócio |
(1) ▶ Exemplo: Teste Unitário
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private ProductRepository productRepository;
@InjectMocks
private OrderServiceImpl orderService;
@Test
void shouldCreateOrderSuccessfully() {
// Dado
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// Quando
Order order = orderService.createOrder(1L, 3);
// Então
assertThat(order).isNotNull();
assertThat(product.getStock()).isEqualTo(7);
verify(orderRepository).save(any(Order.class));
}
@Test
void shouldThrowWhenInsufficientStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
verify(orderRepository, never()).save(any());
}
}
Saída:
// Execução bem-sucedida
4. Testes de Fatia com @WebMvcTest
(1) Testes em Nível de Controller
(1) ▶ Exemplo: Teste de Fatia do Controller
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrderById() throws Exception {
OrderResponse order = new OrderResponse(
1L, "bob", "PENDING", BigDecimal.valueOf(999),
List.of(), Instant.now());
when(orderService.getOrder(1L)).thenReturn(order);
mockMvc.perform(get("/api/v1/orders/1")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
when(orderService.getOrder(999L))
.thenThrow(new ResourceNotFoundException("Order", 999L));
mockMvc.perform(get("/api/v1/orders/999")
.with(httpBasic("bob", "pass123")))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
}
@Test
void shouldReturn400WhenValidationFails() throws Exception {
String body = """
{"productId": null, "quantity": 0}
""";
mockMvc.perform(post("/api/v1/orders")
.with(httpBasic("bob", "pass123"))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
}
Saída:
// Execução bem-sucedida
| Anotação de Teste | Escopo de Carregamento | Nível Aplicável |
|---|---|---|
@WebMvcTest |
Controller + Camada Web | Controller |
@DataJpaTest |
Repository + Camada JPA | Repository |
@SpringBootTest |
ApplicationContext Completo | Teste de Integração |
5. Testes da Camada de Dados com @DataJpaTest
(1) ▶ Exemplo: Teste de Repository
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByNameContaining() {
entityManager.persist(new Product("Laptop Pro", BigDecimal.valueOf(1299), 50));
entityManager.persist(new Product("Laptop Air", BigDecimal.valueOf(899), 30));
entityManager.persist(new Product("Wireless Mouse", BigDecimal.valueOf(29), 200));
List<Product> results = productRepository.findByNameContaining("Laptop");
assertThat(results).hasSize(2);
}
@Test
void shouldFindOutOfStockProducts() {
Product outOfStock = new Product("Old Model", BigDecimal.valueOf(99), 0);
entityManager.persist(outOfStock);
entityManager.persist(new Product("New Model", BigDecimal.valueOf(199), 10));
List<Product> results = productRepository.findOutOfStockProducts();
assertThat(results).hasSize(1);
assertThat(results.get(0).getName()).isEqualTo("Old Model");
}
@Test
void shouldCheckStockBeforeDeduction() {
Product product = entityManager.persist(
new Product("Laptop", BigDecimal.valueOf(999), 5));
Product found = productRepository.findById(product.getId()).orElseThrow();
found.deductStock(3);
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(2);
}
}
Saída:
// Execução bem-sucedida
6. TestContainers: Testes de Integração Containerizados
(1) Testes com Banco de Dados Real
(1) ▶ Exemplo: MySQL TestContainer
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Saída:
// Execução bem-sucedida
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("orderflow_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCreateOrderAndDeductStock() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
Order order = orderService.createOrder(product.getId(), 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(7);
}
}
(2) ▶ Exemplo: Redis TestContainer
@SpringBootTest
@Testcontainers
class RedisCacheIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCacheProductInRedis() {
Product product = productRepository.save(
new Product("Laptop", BigDecimal.valueOf(999), 10));
// Primeira chamada: consulta ao banco de dados
productService.getProduct(product.getId());
// Segunda chamada: cache hit no Redis
Product cached = productService.getProduct(product.getId());
assertThat(cached).isNotNull();
}
}
Saída:
// Execução bem-sucedida
7. Cobertura de Testes
(1) ▶ Exemplo: Configuração do JaCoCo
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
Saída:
// Execução bem-sucedida
| Dimensão de Cobertura | Objetivo | Descrição |
|---|---|---|
| Cobertura de Linha | ≥ 80% | Código de Negócio Principal |
| Cobertura de Branch | ≥ 70% | Lógica if/else |
| Cobertura de Método | ≥ 80% | Métodos Públicos |
8. Exemplo Abrangente: Testando o Processo Principal de Realização de Pedidos no OrderFlow
// OrderServiceTest.java (Teste Unitário)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private ProductRepository productRepository;
@InjectMocks private OrderServiceImpl orderService;
@Test
void createOrder_success() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Order order = orderService.createOrder(1L, 3);
assertThat(order.getStatus()).isEqualTo("PENDING");
assertThat(product.getStock()).isEqualTo(7);
}
@Test
void createOrder_insufficientStock_throws() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
assertThatThrownBy(() -> orderService.createOrder(1L, 5))
.isInstanceOf(InsufficientStockException.class);
}
@Test
void cancelOrder_success_restoresStock() {
Product product = new Product("Laptop", BigDecimal.valueOf(999), 7);
Order order = new Order();
order.setStatus("PENDING");
order.addItem(product, 3);
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
orderService.cancelOrder(1L);
assertThat(order.getStatus()).isEqualTo("CANCELLED");
assertThat(product.getStock()).isEqualTo(10);
}
@Test
void cancelOrder_nonPending_throws() {
Order order = new Order();
order.setStatus("SHIPPED");
when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));
assertThatThrownBy(() -> orderService.cancelOrder(1L))
.isInstanceOf(OrderStateException.class);
}
}
// OrderControllerTest.java (Teste de Fatia)
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
@WithMockUser(roles = "CUSTOMER")
void createOrder_validRequest_returns201() throws Exception {
when(orderService.createOrder(any(), eq("bob"))).thenReturn(
new OrderResponse(1L, "bob", "PENDING", BigDecimal.valueOf(999), List.of(), Instant.now()));
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void createOrder_unauthenticated_returns401() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":1,\"quantity\":3}"))
.andExpect(status().isUnauthorized());
}
}
❓ Perguntas Frequentes
static), então testes subsequentes os reutilizam diretamente. Overhead típico: 30 segundos na primeira execução, e menos de 5 segundos nas execuções seguintes. Recomendamos pré-aquecer as imagens no ambiente de CI.SyncTaskExecutor) para simplificar os testes.📖 Resumo
- Pirâmide de Testes: 70% testes unitários + 20% testes de integração + 10% testes E2E
- Use
@ExtendWith(MockitoExtension.class)+@Mockpara testes unitários - Controller para testar:
@WebMvcTest+MockMvc - Repository para testar:
@DataJpaTest+ H2 - TestContainers: Testar usando containers de banco de dados reais para eliminar discrepâncias do H2
- JaCoCo monitora a cobertura de testes; código de negócio principal ≥ 80%
📝 Exercícios
-
Exercício Básico (Dificuldade: ⭐): Escreva cinco testes unitários para o OrderService que cubram cenários como criar um pedido, estoque insuficiente, cancelar um pedido, exceções de status e recursos não encontrados.
-
Exercício Avançado (Dificuldade ⭐⭐): Use
@WebMvcTestpara escrever testes de fatia para o OrderController, cobrindo cenários como requisições normais, falhas de validação, erros 404 e erros 401 (Não Autorizado). Use@DataJpaTestpara escrever testes de consulta para o ProductRepository. -
Desafio (Dificuldade: ⭐⭐⭐): Integre TestContainers (MySQL + Redis) e escreva um teste de integração end-to-end—cobrindo todo o processo desde criar um produto, realizar um pedido e deduzir estoque, cache hit no Redis, até cancelar um pedido e restaurar estoque, com taxa de cobertura ≥ 80%.



