TDD Testes .NET C# Clean Architecture

TDD na prática com .NET: Red-Green-Refactor, test doubles e design dirigido por testes

Como aplicar TDD de verdade em projetos .NET: o ciclo Red-Green-Refactor, quando usar mocks vs stubs vs fakes, e como TDD melhora o design além de garantir cobertura.

N
Neryx Digital Architects
15 de março de 2026
13 min de leitura
240 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

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.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.