Design de Software

TDD — Test-Driven Development

Escreva o teste antes do código — guia completo com Red/Green/Refactor, pirâmide de testes, mocks, fakes e BDD

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::toString

Banco 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);
    }
}