TDD não é sobre cobertura de código
A maioria das pessoas aprende TDD como "escreva o teste antes do código". Isso é tecnicamente correto, mas perde o ponto mais importante: TDD é uma técnica de design. O teste é um instrumento para pensar a API antes de implementá-la.
Quando você escreve o teste primeiro, está respondendo: "Como eu gostaria de usar esse código?" Antes de pensar em como implementar, você pensa em como consumir. O resultado quase sempre é uma API mais limpa, mais simples, com menos acoplamento.
Cobertura de código é um subproduto — não o objetivo.
O ciclo Red-Green-Refactor
Red: escreva um teste que falha. Apenas um, pequeno, que especifica o próximo comportamento que você quer implementar. Rode — ele deve falhar por razão certa (não por erro de compilação).
Green: escreva o mínimo de código necessário para o teste passar. O mínimo mesmo — pode ser feio, pode ser hardcoded. O objetivo é passar no teste, nada mais.
Refactor: melhore o código sem mudar o comportamento. Elimine duplicação, melhore nomes, extraia métodos. Os testes garantem que você não quebrou nada.
Vamos ver na prática implementando um calculador de desconto:
Iteração 1 — Red
// CalculadorDescontoTests.cs
public class CalculadorDescontoTests
{
[Fact]
public void Deve_retornar_zero_quando_valor_abaixo_do_minimo()
{
var calc = new CalculadorDesconto();
var desconto = calc.Calcular(valorPedido: 99m, clienteVip: false);
desconto.Should().Be(0m);
}
}
Compila? Não — CalculadorDesconto não existe. Crie a classe mínima para compilar:
public class CalculadorDesconto
{
public decimal Calcular(decimal valorPedido, bool clienteVip) => 0m;
}
Teste passa. ✅
Iteração 2 — Red
[Fact]
public void Deve_aplicar_10_porcento_para_pedido_acima_de_100()
{
var calc = new CalculadorDesconto();
var desconto = calc.Calcular(valorPedido: 150m, clienteVip: false);
desconto.Should().Be(15m); // 10% de 150
}
Iteração 2 — Green
public decimal Calcular(decimal valorPedido, bool clienteVip)
{
if (valorPedido >= 100m) return valorPedido * 0.10m;
return 0m;
}
Iteração 3 — Red (cliente VIP)
[Fact]
public void Deve_aplicar_20_porcento_para_cliente_vip_acima_de_100()
{
var calc = new CalculadorDesconto();
var desconto = calc.Calcular(valorPedido: 200m, clienteVip: true);
desconto.Should().Be(40m); // 20% de 200
}
Iteração 3 — Green + Refactor
public decimal Calcular(decimal valorPedido, bool clienteVip)
{
if (valorPedido < 100m) return 0m;
var percentual = clienteVip ? 0.20m : 0.10m;
return valorPedido * percentual;
}
Todos os 3 testes passam. Refactor foi aplicado na fase Green→Refactor: extraímos o percentual como variável local com nome expressivo.
Test Doubles: o vocabulário correto
Há confusão generalizada sobre nomenclatura. Martin Fowler define cinco tipos:
Dummy: objeto passado mas nunca usado. Preenche parâmetros obrigatórios que não importam para o teste.
Stub: retorna respostas pré-definidas. Não verifica chamadas — apenas alimenta o sistema com dados.
Fake: implementação funcional simplificada. Ex: repositório em memória, banco SQLite.
Spy: registra as chamadas recebidas para verificação posterior. Uma versão manual do mock.
Mock: objeto pré-programado com expectativas. Verifica se foi chamado da forma esperada.
// Stub — não verifica chamadas, só retorna dados
var clienteRepo = Substitute.For<IClienteRepository>();
clienteRepo.GetByIdAsync(clienteId, default)
.Returns(new Cliente { Id = clienteId, IsVip = true });
// Mock — verifica se o método foi chamado
var emailService = Substitute.For<IEmailService>();
// ... executa o código ...
await emailService.Received(1)
.EnviarConfirmacaoAsync(Arg.Is<Email>(e => e.Value == "cliente@email.com"));
// Fake — implementação em memória
public class FakeClienteRepository : IClienteRepository
{
private readonly Dictionary<Guid, Cliente> _store = new();
public Task<Cliente?> GetByIdAsync(Guid id, CancellationToken ct) =>
Task.FromResult(_store.GetValueOrDefault(id));
public Task AddAsync(Cliente cliente, CancellationToken ct)
{
_store[cliente.Id] = cliente;
return Task.CompletedTask;
}
}
Quando usar cada um: use Stub para alimentar dependências que retornam dados. Use Mock quando o comportamento do código em teste depende de como ele chama uma dependência (não só o que ela retorna). Use Fake quando a implementação real é lenta/complexa mas você precisa de comportamento real (ex: repositório com query).
TDD e design: o efeito Listen to Your Tests
Quando um teste é difícil de escrever, é um sinal do design. Se você precisa de 10 linhas de setup para testar uma classe, ela tem muitas responsabilidades. Se o construtor precisa de 8 dependências, a classe está fazendo coisa demais.
Veja um exemplo de código que "funciona" mas tem design ruim — revelado pelos testes:
// Difícil de testar — dependência hardcoded, muitas responsabilidades
public class ProcessadorPedido
{
public async Task ProcessarAsync(Guid pedidoId)
{
var db = new AppDbContext(); // não injetável
var pedido = await db.Pedidos.FindAsync(pedidoId);
pedido.Status = "Processado";
await db.SaveChangesAsync();
var smtp = new SmtpClient("smtp.empresa.com"); // não injetável
await smtp.SendMailAsync(...);
Console.WriteLine($"Pedido {pedidoId} processado"); // log hardcoded
}
}
Reescrevendo com TDD em mente:
// Fácil de testar — dependências injetadas, responsabilidades separadas
public class ProcessadorPedido
{
private readonly IPedidoRepository _repo;
private readonly IUnitOfWork _uow;
private readonly IEmailService _email;
private readonly ILogger<ProcessadorPedido> _logger;
public ProcessadorPedido(
IPedidoRepository repo,
IUnitOfWork uow,
IEmailService email,
ILogger<ProcessadorPedido> logger)
{
_repo = repo;
_uow = uow;
_email = email;
_logger = logger;
}
public async Task ProcessarAsync(Guid pedidoId, CancellationToken ct)
{
var pedido = await _repo.GetByIdAsync(pedidoId, ct)
?? throw new PedidoNotFoundException(pedidoId);
pedido.Processar();
await _uow.CommitAsync(ct);
await _email.NotificarProcessamentoAsync(pedido.ClienteEmail, ct);
_logger.LogInformation("Pedido {PedidoId} processado com sucesso", pedidoId);
}
}
Testando casos de erro com TDD
TDD força você a pensar nos casos de falha desde o início — e não como afterthought:
public class ProcessadorPedidoTests
{
private readonly IPedidoRepository _repo = Substitute.For<IPedidoRepository>();
private readonly IUnitOfWork _uow = Substitute.For<IUnitOfWork>();
private readonly IEmailService _email = Substitute.For<IEmailService>();
private readonly ProcessadorPedido _sut;
public ProcessadorPedidoTests()
{
_sut = new ProcessadorPedido(_repo, _uow, _email, NullLogger<ProcessadorPedido>.Instance);
}
[Fact]
public async Task Deve_processar_pedido_e_notificar_cliente()
{
var pedidoId = Guid.NewGuid();
var pedido = PedidoBuilder.CriarConfirmado(pedidoId);
_repo.GetByIdAsync(pedidoId, default).Returns(pedido);
await _sut.ProcessarAsync(pedidoId, default);
await _uow.Received(1).CommitAsync(default);
await _email.Received(1).NotificarProcessamentoAsync(
Arg.Is<Email>(e => e == pedido.ClienteEmail), default);
}
[Fact]
public async Task Deve_lancar_excecao_quando_pedido_nao_encontrado()
{
_repo.GetByIdAsync(Arg.Any<Guid>(), default).Returns((Pedido?)null);
var act = () => _sut.ProcessarAsync(Guid.NewGuid(), default);
await act.Should().ThrowAsync<PedidoNotFoundException>();
await _uow.DidNotReceive().CommitAsync(default);
await _email.DidNotReceiveWithAnyArgs().NotificarProcessamentoAsync(default!, default);
}
[Fact]
public async Task Nao_deve_enviar_email_quando_commit_falhar()
{
var pedidoId = Guid.NewGuid();
_repo.GetByIdAsync(pedidoId, default).Returns(PedidoBuilder.CriarConfirmado(pedidoId));
_uow.CommitAsync(default).ThrowsAsync(new Exception("DB error"));
var act = () => _sut.ProcessarAsync(pedidoId, default);
await act.Should().ThrowAsync<Exception>();
await _email.DidNotReceiveWithAnyArgs().NotificarProcessamentoAsync(default!, default);
}
}
Test Builders: eliminando setup repetitivo
À medida que o projeto cresce, o setup dos testes fica repetitivo. Test Builders (ou Object Mothers) centralizam a criação de objetos de teste:
public class PedidoBuilder
{
private ClienteId _clienteId = new(Guid.NewGuid());
private StatusPedido _status = StatusPedido.Rascunho;
private List<ItemPedido> _itens = new();
public static PedidoBuilder Novo() => new();
public PedidoBuilder ComCliente(Guid clienteId)
{
_clienteId = new ClienteId(clienteId);
return this;
}
public PedidoBuilder Confirmado()
{
_status = StatusPedido.Confirmado;
return this;
}
public PedidoBuilder ComItem(decimal preco = 100m, int quantidade = 1)
{
_itens.Add(ItemPedido.Criar(
new PedidoId(Guid.NewGuid()),
new ProdutoId(Guid.NewGuid()),
quantidade,
Money.Of(preco, "BRL")));
return this;
}
public Pedido Build()
{
var pedido = Pedido.Criar(_clienteId, Endereco.Padrao());
foreach (var item in _itens) pedido.AdicionarItemDireto(item);
if (_status == StatusPedido.Confirmado) pedido.Confirmar();
return pedido;
}
// Shortcut estático para os casos mais comuns
public static Pedido CriarConfirmado(Guid? id = null) =>
Novo().ComItem(150m).Confirmado().Build();
}
// Uso nos testes — legível e sem repetição
var pedido = PedidoBuilder.Novo()
.ComCliente(clienteId)
.ComItem(preco: 200m, quantidade: 3)
.ComItem(preco: 50m, quantidade: 1)
.Confirmado()
.Build();
TDD em código existente (Legacy)
Aplicar TDD em código legado segue uma abordagem diferente:
1. Caracterization Tests: antes de alterar qualquer coisa, escreva testes que documentam o comportamento atual — mesmo que estranho. Eles garantem que você não vai quebrar o que já funciona.
2. Seam Points: encontre pontos onde você pode injetar dependências sem reescrever tudo. Extraia interfaces, use Extract Method, quebre dependências hardcoded.
3. Strangler Fig Pattern: construa o código novo com TDD ao lado do legado, migrando gradualmente.
Conclusão
TDD é um investimento com retorno composto. O primeiro dia é mais lento — você escreve teste antes de código. Mas no segundo mês, o custo de cada mudança cai: você tem uma rede de segurança que permite refatorar com confiança, detecta regressões instantaneamente e documenta o comportamento esperado de forma executável.
O benefício mais subestimado é o design: código que nasce testável tem menos acoplamento, responsabilidades mais claras e interfaces mais simples. Não porque o desenvolvedor foi disciplinado — mas porque os testes puniam o design ruim antes de ele ir para produção.