TDD é uma disciplina de desenvolvimento onde o teste é escrito antes do código de produção. O ciclo é repetido continuamente em iterações curtas. O objetivo real não é ter 100% de cobertura — é forçar um design desacoplado e pensar na interface antes da implementação.
Código difícil de testar é código mal projetado. O teste age como primeiro “cliente” da sua API.
Red / Green / Refactor — O Ciclo Detalhado
┌─────────────────────────────────────────────────┐
│ │
│ 1. RED — escreva um teste que falha │
│ ↓ │
│ 2. GREEN — escreva o mínimo para passar │
│ ↓ │
│ 3. REFACTOR — melhore sem quebrar o teste │
│ ↓ │
│ volta para 1 com o próximo comportamento │
│ │
└─────────────────────────────────────────────────┘Red: o teste descreve o comportamento desejado. Deve falhar por uma razão clara — o comportamento não existe ainda. Se o teste passa sem você escrever código, algo está errado (teste inútil ou comportamento já existe).
Green: escreva o código mais simples possível para fazer o teste passar. Não antecipe casos futuros. “Código simples” pode incluir hardcoding temporário.
Refactor: com os testes verdes e servindo como rede de segurança, melhore o código: renomeie, extraia métodos, elimine duplicações, melhore legibilidade. Os testes garantem que o comportamento não mudou.
Exemplo Passo a Passo — Carrinho de Compras
// ═══════ ITERAÇÃO 1 — adicionar item ao carrinho ═══════
// PASSO 1 — RED: Cart nem existe ainda
class CartTest {
@Test
void shouldAddItemToCart() {
Cart cart = new Cart();
cart.add(new CartItem("produto-1", Money.of(50.00), 1));
assertThat(cart.itemCount()).isEqualTo(1);
assertThat(cart.total()).isEqualTo(Money.of(50.00));
}
}
// PASSO 2 — GREEN: implementação mínima
public class Cart {
private final List<CartItem> items = new ArrayList<>();
public void add(CartItem item) {
items.add(item);
}
public int itemCount() {
return items.size();
}
public Money total() {
return items.stream()
.map(i -> i.price().multiply(i.quantity()))
.reduce(Money.ZERO, Money::add);
}
}
// PASSO 3 — REFACTOR: sem mudança de comportamento necessária ainda
// ═══════ ITERAÇÃO 2 — quantidade ao adicionar o mesmo produto ═══════
// PASSO 1 — RED
@Test
void shouldAccumulateQuantityForSameProduct() {
Cart cart = new Cart();
cart.add(new CartItem("produto-1", Money.of(50.00), 1));
cart.add(new CartItem("produto-1", Money.of(50.00), 2)); // mesmo produto
assertThat(cart.itemCount()).isEqualTo(1); // 1 linha única
assertThat(cart.total()).isEqualTo(Money.of(150.00)); // 3 unidades * R$50
}
// PASSO 2 — GREEN: altera o comportamento de add()
public void add(CartItem item) {
Optional<CartItem> existing = items.stream()
.filter(i -> i.productId().equals(item.productId()))
.findFirst();
if (existing.isPresent()) {
// substitui com quantidade acumulada
items.remove(existing.get());
items.add(existing.get().withQuantity(existing.get().quantity() + item.quantity()));
} else {
items.add(item);
}
}
// PASSO 3 — REFACTOR: extrair método
public void add(CartItem item) {
findItem(item.productId())
.ifPresentOrElse(
existing -> replaceWithMerged(existing, item),
() -> items.add(item)
);
}
private Optional<CartItem> findItem(String productId) {
return items.stream().filter(i -> i.productId().equals(productId)).findFirst();
}
private void replaceWithMerged(CartItem existing, CartItem newItem) {
items.remove(existing);
items.add(existing.withQuantity(existing.quantity() + newItem.quantity()));
}
// ═══════ ITERAÇÃO 3 — validação: quantidade negativa ═══════
// RED
@Test
void shouldRejectNegativeQuantity() {
Cart cart = new Cart();
assertThatThrownBy(() -> cart.add(new CartItem("produto-1", Money.of(50.00), -1)))
.isInstanceOf(InvalidCartItemException.class)
.hasMessage("Quantidade deve ser positiva");
}
// GREEN: validação no construtor de CartItem
public CartItem(String productId, Money price, int quantity) {
if (quantity <= 0) throw new InvalidCartItemException("Quantidade deve ser positiva");
this.productId = productId;
this.price = price;
this.quantity = quantity;
}Pirâmide de Testes
/\
/ \
/ E2E\ Poucos, lentos, caros
/──────\ Testam o sistema inteiro
/ \
/Integração\ Moderados — testam colaboração
/────────────\ entre componentes reais
/ \
/ Unitários \ Muitos, rápidos, baratos
/──────────────────\ Testam comportamento de unidades
/ \ isoladas
└──────────────────────┘
Unitários: 70-80% — milissegundos por teste
Integração: 15-20% — segundos por teste (banco, HTTP)
E2E: 5-10% — minutos por suite (aplicação completa)Unitários testam uma unidade de comportamento (não necessariamente uma classe) isolada de dependências externas. Use fakes ou mocks para dependências.
Integração testam a colaboração entre componentes reais: repositório com banco, controller com serialização, cliente HTTP com servidor real. Use Testcontainers para banco.
E2E testam o sistema ponta a ponta como um usuário faria. São valiosos mas devem ser poucos — são frágeis e lentos.
Testando Comportamento vs. Implementação
Teste o comportamento observável da unidade, não os detalhes internos de como ela foi implementada.
// ❌ Teste de implementação — frágil, quebra em qualquer refactoring
@Test
void shouldCallRepositorySaveOnce() {
OrderService service = new OrderService(mockRepo, mockPayment);
service.placeOrder(command);
// Verifica detalhes internos — se você mudar para cache, o teste quebra
verify(mockRepo, times(1)).save(any(Order.class));
verify(mockRepo, never()).delete(any());
verify(mockPayment, times(1)).charge(any(), any());
}
// ✅ Teste de comportamento — resiliente a refactoring
@Test
void shouldPersistOrderAfterSuccessfulPayment() {
FakeOrderRepository repo = new FakeOrderRepository();
FakePaymentGateway payment = new FakePaymentGateway();
OrderService service = new OrderService(repo, payment);
OrderId orderId = service.placeOrder(buildCommand());
// Verifica resultado observável: o pedido foi salvo e pode ser encontrado
assertThat(repo.findById(orderId)).isPresent();
assertThat(repo.findById(orderId).get().status()).isEqualTo(OrderStatus.PENDING);
}Mocks vs Stubs vs Fakes vs Spies
Cada tipo de test double serve a um propósito diferente. Usar o errado torna os testes frágeis.
// STUB — retorna respostas pré-programadas, não verifica chamadas
// Use quando: precisa controlar o comportamento de uma dependência
public class StubPaymentGateway implements PaymentGateway {
private final PaymentResult fixedResult;
public StubPaymentGateway(PaymentResult result) {
this.fixedResult = result;
}
@Override
public PaymentResult charge(Money amount, String customerId, String paymentMethodId) {
return fixedResult; // sempre retorna o mesmo resultado
}
}
// Uso:
PaymentGateway gateway = new StubPaymentGateway(PaymentResult.success("pay_123"));
// FAKE — implementação simplificada mas funcional
// Use quando: precisa de comportamento real mas leve (ex: banco em memória)
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
@Override
public void save(Order order) {
store.put(order.id().value(), order);
}
@Override
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(store.get(id.value()));
}
public int count() { return store.size(); }
}
// SPY — wrapper que registra chamadas para verificação posterior
// Use quando: precisa verificar se algo foi chamado sem substituir o comportamento
public class SpyNotificationService implements NotificationService {
private final NotificationService delegate;
private final List<String> notifiedEmails = new ArrayList<>();
public SpyNotificationService(NotificationService delegate) {
this.delegate = delegate;
}
@Override
public void notifyOrderPlaced(Order order, String email) {
notifiedEmails.add(email);
delegate.notifyOrderPlaced(order, email); // delega para o real
}
public List<String> notifiedEmails() { return notifiedEmails; }
}
// MOCK — substituto com expectativas pré-configuradas (Mockito)
// Use quando: precisa verificar interações sem implementar um fake
@Test
void shouldNotifyCustomerAfterOrderPlaced() {
NotificationService mockNotification = mock(NotificationService.class);
OrderService service = new OrderService(fakeRepo, fakePayment, mockNotification);
service.placeOrder(buildCommand("cliente@email.com"));
// verifica que o mock foi chamado com os argumentos corretos
verify(mockNotification).notifyOrderPlaced(
any(Order.class),
eq("cliente@email.com")
);
}Regra geral:
- Use fakes para dependências com estado (repositórios, caches)
- Use stubs para dependências que retornam dados (APIs externas, configs)
- Use spies quando precisar do comportamento real + verificar chamadas
- Use mocks com moderação — testes com muitos mocks verificam implementação, não comportamento
TDD com Dependências Externas — Ports e Fakes
TDD funciona melhor com Ports & Adapters: o port (interface) é o contrato; o fake é a implementação para testes.
// PORT — contrato definido para a dependência externa
public interface ExchangeRatePort {
BigDecimal getRate(Currency from, Currency to);
}
// FAKE para testes — comportamento controlado
public class FakeExchangeRatePort implements ExchangeRatePort {
private final Map<String, BigDecimal> rates = new HashMap<>();
public void addRate(Currency from, Currency to, BigDecimal rate) {
rates.put(from.code() + "-" + to.code(), rate);
}
@Override
public BigDecimal getRate(Currency from, Currency to) {
String key = from.code() + "-" + to.code();
if (!rates.containsKey(key)) {
throw new RateNotFoundException("Taxa não configurada no fake: " + key);
}
return rates.get(key);
}
}
// TESTE USANDO O FAKE
class CurrencyConversionServiceTest {
private final FakeExchangeRatePort exchangeRate = new FakeExchangeRatePort();
private final CurrencyConversionService service = new CurrencyConversionService(exchangeRate);
@BeforeEach
void setUp() {
exchangeRate.addRate(Currency.USD, Currency.BRL, BigDecimal.valueOf(5.20));
exchangeRate.addRate(Currency.EUR, Currency.BRL, BigDecimal.valueOf(5.80));
}
@Test
void shouldConvertUsdToBrl() {
Money usd100 = Money.of(100, Currency.USD);
Money result = service.convert(usd100, Currency.BRL);
assertThat(result).isEqualTo(Money.of(520, Currency.BRL));
}
}Test Doubles em Java (Mockito) e TypeScript (Jest/Vitest)
// JAVA — Mockito
class OrderNotificationTest {
@Mock
private NotificationService notificationService;
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderService orderService;
@BeforeEach
void setUp() { MockitoAnnotations.openMocks(this); }
@Test
void shouldSendNotificationWhenOrderIsPlaced() {
// given
Order savedOrder = buildOrder();
when(orderRepository.findById(any())).thenReturn(Optional.of(savedOrder));
// when
orderService.placeOrder(buildCommand());
// then
verify(notificationService).notifyOrderPlaced(any(), eq("cliente@email.com"));
}
@Test
void shouldNotSendNotificationWhenPaymentFails() {
doThrow(new PaymentFailedException("recusado"))
.when(paymentGateway).charge(any(), any(), any());
assertThatThrownBy(() -> orderService.placeOrder(buildCommand()))
.isInstanceOf(PaymentFailedException.class);
verifyNoInteractions(notificationService);
}
}// TYPESCRIPT — Jest/Vitest
describe('CartService', () => {
let cartService: CartService;
let mockPriceRepository: jest.Mocked<PriceRepository>;
let mockEventPublisher: jest.Mocked<EventPublisher>;
beforeEach(() => {
// Cria mocks tipados automaticamente
mockPriceRepository = {
getPrice: jest.fn(),
};
mockEventPublisher = {
publish: jest.fn(),
};
cartService = new CartService(mockPriceRepository, mockEventPublisher);
});
it('should calculate total with current prices', async () => {
// arrange
mockPriceRepository.getPrice.mockResolvedValueOnce(99.90);
// act
const cart = await cartService.addItem({ productId: 'p1', quantity: 2 });
// assert
expect(cart.total).toBe(199.80);
expect(mockPriceRepository.getPrice).toHaveBeenCalledWith('p1');
});
it('should publish CartItemAdded event', async () => {
mockPriceRepository.getPrice.mockResolvedValue(50.00);
await cartService.addItem({ productId: 'p1', quantity: 1 });
expect(mockEventPublisher.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: 'CartItemAdded',
productId: 'p1',
quantity: 1,
})
);
});
});Casos Difíceis — Soluções Práticas
Tempo (relógio): injete um Clock em vez de usar Instant.now() direto.
// ❌ impossível de testar deterministicamente
public Instant expiresAt() { return Instant.now().plus(Duration.ofDays(30)); }
// ✅ Clock injetado — controlável nos testes
public class Subscription {
private final Clock clock;
public Instant expiresAt() { return clock.instant().plus(Duration.ofDays(30)); }
}
// No teste:
Clock fixedClock = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneOffset.UTC);
Subscription sub = new Subscription(fixedClock);
assertThat(sub.expiresAt()).isEqualTo(Instant.parse("2024-02-14T10:00:00Z"));Randomness (UUIDs, tokens): injete um gerador.
// ❌ impossível de testar o valor gerado
public class Order {
private final String id = UUID.randomUUID().toString();
}
// ✅ gerador injetável
public interface IdGenerator { String generate(); }
public class Order {
private final String id;
public Order(IdGenerator idGen) { this.id = idGen.generate(); }
}
// Em testes: () -> "fixed-id-for-test"
// Em produção: UUID::randomUUID::toStringBanco de dados: use Testcontainers para testes de integração — banco real, isolado por teste.
@Testcontainers
class ProductRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void shouldPersistProduct() {
// usa banco PostgreSQL real dentro de container Docker
}
}UI: teste lógica de apresentação separada da renderização (ViewModel pattern), e use testes de snapshot para componentes.
BDD com Cucumber — Given/When/Then
BDD (Behavior-Driven Development) é TDD com ênfase em linguagem de negócio. Testes são escritos em linguagem natural e executados como código.
# src/test/resources/features/cart.feature
Feature: Carrinho de Compras
Como cliente do e-commerce
Quero adicionar e remover produtos do carrinho
Para poder finalizar minha compra
Scenario: Adicionar produto ao carrinho
Given que o produto "Notebook Dell" custa R$ 3500,00
When o cliente adiciona 1 unidade do "Notebook Dell" ao carrinho
Then o carrinho deve conter 1 item
And o total deve ser R$ 3500,00
Scenario: Desconto para cliente VIP
Given que o cliente é VIP
And que o produto "Mouse Logitech" custa R$ 200,00
When o cliente adiciona 1 unidade ao carrinho
And aplica o desconto VIP
Then o total deve ser R$ 180,00
Scenario Outline: Validação de quantidade
When o cliente tenta adicionar <quantidade> unidades
Then deve receber o erro "<mensagem>"
Examples:
| quantidade | mensagem |
| 0 | Quantidade deve ser positiva |
| -5 | Quantidade deve ser positiva |
| 1001 | Quantidade máxima é 1000 |// Step definitions — conectam o Gherkin ao código Java
@CucumberContextConfiguration
@SpringBootTest
public class CartStepDefinitions {
private Cart cart;
private Exception thrownException;
private Map<String, Money> productPrices = new HashMap<>();
@Given("que o produto {string} custa R$ {double}")
public void productHasPrice(String name, double price) {
productPrices.put(name, Money.of(price));
}
@When("o cliente adiciona {int} unidade do {string} ao carrinho")
public void clientAddsToCart(int quantity, String productName) {
cart = new Cart();
Money price = productPrices.get(productName);
cart.add(new CartItem(productName, price, quantity));
}
@Then("o carrinho deve conter {int} item")
public void cartShouldContainItems(int expectedCount) {
assertThat(cart.itemCount()).isEqualTo(expectedCount);
}
@And("o total deve ser R$ {double}")
public void totalShouldBe(double expectedTotal) {
assertThat(cart.total()).isEqualTo(Money.of(expectedTotal));
}
@When("o cliente tenta adicionar {int} unidades")
public void clientTriesToAdd(int quantity) {
try {
cart = new Cart();
cart.add(new CartItem("produto-teste", Money.of(10.00), quantity));
} catch (Exception e) {
thrownException = e;
}
}
@Then("deve receber o erro {string}")
public void shouldReceiveError(String expectedMessage) {
assertThat(thrownException)
.isInstanceOf(InvalidCartItemException.class)
.hasMessage(expectedMessage);
}
}