AAAlison AmorimBlog
alisonamorim.com ↗

Práticas

TDD: Repensando a Forma de Desenvolver com Simplicidade e Autonomia

TDD pela metáfora do labirinto: comece pelo resultado esperado. Exemplos em C# de teste de valor e de comportamento com mocks, sem tentativa e erro.

  • 7 min de leitura
  • Práticas
Fonte do texto
Homem diante de um labirinto neon em que um único caminho verde iluminado leva ao centro, sob um sol poente rosa
Neste artigo · 7 seções
  1. O Desenvolvimento Tradicional: Entre a Tentativa e o Erro
  2. A Metáfora do Labirinto: Entendendo o Problema
  3. O TDD na Prática: Começando pelo Final
  4. Quando Não Há um Output Direto: Testando o Comportamento
  5. Por Que o TDD Oferece Autonomia e Agilidade
  6. Conclusão: Mudando de Perspectiva
  7. Insights & Takeaways

A maioria dos desenvolvedores compreende a importância dos testes, mas poucos realmente abraçam o Test-Driven Development (TDD) como uma metodologia transformadora. O TDD não é apenas sobre garantir qualidade; é sobre conquistar autonomia e agilidade no desenvolvimento. Ao inverter a ordem tradicional e começar pelos testes, mudamos fundamentalmente como abordamos a resolução de problemas no código.

O Desenvolvimento Tradicional: Entre a Tentativa e o Erro

Na prática atual, a maioria dos desenvolvedores segue um fluxo previsível: criam o código, realizam testes manuais e, apenas ao final, implementam os testes unitários. Este processo, embora comum, carrega consigo uma série de ineficiências que prejudicam tanto a qualidade quanto a produtividade.

Quando começamos pelo código, nos vemos condenados à lógica da tentativa e erro. Escrevemos linhas que não temos certeza se funcionarão, exigindo reescritas constantes. Cada mudança demanda novos testes manuais, criando um ciclo repetitivo de validação e correção. O resultado é um processo fatigante que consome tempo e esforço além do necessário.

Um problema frequente nesta abordagem é o acúmulo de código comentado e funções descartadas. Por vezes, não queremos perder aquele código que pareceu promissor; outras vezes, a pressão de prazos nos impede de fazer uma limpeza adequada; às vezes, simplesmente esquecemos. O resultado é um repositório poluído, difícil de manter e compreender. Multiplicamos as possibilidades de erro e desperdiçamos recursos valiosos tentando navegar por esta bagunça conceitual.

A Metáfora do Labirinto: Entendendo o Problema

Para explicar aos meus liderados como devemos enxergar realmente os testes, recorro a uma analogia simples mas poderosa: um jogo infantil de labirinto. Imagine um desses quebra-cabeças onde um personagem precisa ir do ponto A até o ponto B. A dificuldade reside no fato de que existem múltiplas entradas possíveis no início, mas apenas um único caminho leva ao destino final. Quanto mais complexo o labirinto, mais desafiador é encontrar essa trilha correta.

Dois passatempos de labirinto: uma menina procura o único caminho até o piano, e a Mônica precisa descobrir qual das mangueiras A, B, C ou D leva a água até a planta

Nossos desafios no desenvolvimento são precisamente assim. Temos um problema a resolver, mas frequentemente existem várias formas de resolvê-lo. A pergunta crucial é: como encontrar o caminho correto entre tantas possibilidades? A resposta está em um truque simples: trapacear.

Se você começar pelo início do labirinto, terá múltiplas entradas para testar. Mas e se você começasse pelo final? Se começasse de onde há apenas um único caminho, seria incomparavelmente mais fácil identificar qual é a entrada correta. Essa mudança de perspectiva é exatamente o que o TDD proporciona.

O TDD na Prática: Começando pelo Final

O TDD inverte a ordem. Em vez de escrever o código e depois testá-lo, começamos pelo final. Começamos pelo output. Pensemos em como será testado, considerando qual será a entrada do teste e qual será a saída esperada.

Tomemos exemplos concretos. Se estamos desenvolvendo uma função de soma simples, o input seria "1, 1" e o output esperado seria "2". Se estamos criando um cadastro de usuário, o input são os dados do usuário e o output esperado é o ID do usuário ou os dados salvos na base de dados. Se estamos processando uma ordem de trade, o input é a informação da ordem e o output é o relatório de execução. Em cada caso, sabemos exatamente qual é o destino.

Exemplo 1: Uma Função Simples de Soma

Vamos começar pelo teste. Você define exatamente o que espera:

[TestClass]
public class CalculadoraTests
{
    [TestMethod]
    public void Somar_1Mais1_DeveRetornar2()
    {
        // Arrange
        var calculadora = new Calculadora();

        // Act
        var resultado = calculadora.Somar(1, 1);

        // Assert
        Assert.AreEqual(2, resultado);
    }
}

Aqui está o teste. Você sabe que chama Somar(1, 1) e espera 2. Agora vem o truque: em vez de ficar perdido tentando implementar, você sabe exatamente qual é o destino. Implementa a classe:

public class Calculadora
{
    public int Somar(int a, int b)
    {
        return a + b;
    }
}

Sem incerteza. Sem tentativa e erro. Você sabia para onde ia.

Exemplo 2: Um Cadastro de Usuário

Agora um exemplo mais realista. Você precisa criar um serviço que cadastre usuários. Novamente, começamos pelo teste:

[TestClass]
public class UsuarioServiceTests
{
    [TestMethod]
    public void CriarUsuario_ComDadosValidos_DeveRetornarIdValido()
    {
        // Arrange
        var usuarioService = new UsuarioService();
        var usuario = new Usuario
        {
            Nome = "João Silva",
            Email = "joao@example.com"
        };

        // Act
        var idCriado = usuarioService.Criar(usuario);

        // Assert
        Assert.IsTrue(idCriado > 0);
        Assert.AreEqual("João Silva", usuario.Nome);
    }
}

Você sabe que o resultado deve ser um ID maior que zero. Você sabe que o usuário deve ser salvo com seus dados intactos. Agora implementa:

public class UsuarioService
{
    private int _proximoId = 1;

    public int Criar(Usuario usuario)
    {
        usuario.Id = _proximoId;
        _proximoId++;
        // Aqui entraria a lógica de persistência
        return usuario.Id;
    }
}

public class Usuario
{
    public int Id { get; set; }
    public string Nome { get; set; }
    public string Email { get; set; }
}

De novo, sem distrações. Você construiu exatamente o que o teste exigia, nada mais, nada menos. Sem código comentado. Sem exploração desnecessária de caminhos alternativos.

Sabendo qual é o final, podemos começar a montar nosso teste com total clareza. Você sabe que seu Assert será "2" no caso da soma, ou que haverá um ID válido retornado com os dados cadastrais. Com esse objetivo claro, você cria seu método de teste, adiciona o Assert, e então começa a escrever o código que fará funcionar.

Enquanto escreve, você já sabe que precisará instanciar ou receber por injeção de dependência certos componentes. Se uma classe ainda não existe, você a cria com apenas seu nome. Você chama o método "CreateUser" no seu teste. Se está usando Visual Studio ou Rider, um simples "Ctrl + ." ou "Alt + Enter" oferece a opção de criar automaticamente o método dentro da classe. Finalmente, você implementa o algoritmo necessário para completar a tarefa.

Essa abordagem transforma o desenvolvimento em um processo direto e eficiente. Você não está adivinhando; está construindo com propósito.

Quando Não Há um Output Direto: Testando o Comportamento

Nem sempre temos um output direto e mensurável. Às vezes, você está desenvolvendo uma funcionalidade cujo resultado não é um valor retornado, mas sim um comportamento esperado. Nestes casos, continuamos aplicando TDD, mas testamos o comportamento ao invés do output.

Consideremos um exemplo: você está desenvolvendo um serviço de notificação. Quando um usuário é criado, o sistema deve enviar um email de boas-vindas. Qual é o "output" aqui? Não é um número retornado. O comportamento esperado é que o serviço de email foi chamado corretamente com os dados do usuário.

[TestClass]
public class NotificacaoServiceTests
{
    [TestMethod]
    public void AoGerarNotificacao_DeveEnviarEmailParaUsuario()
    {
        // Arrange
        var emailServiceMock = new Mock<IEmailService>();
        var notificacaoService = new NotificacaoService(emailServiceMock.Object);
        var usuario = new Usuario
        {
            Nome = "Maria Silva",
            Email = "maria@example.com"
        };

        // Act
        notificacaoService.GerarNotificacao(usuario);

        // Assert - Verificamos que o comportamento ocorreu
        emailServiceMock.Verify(
            x => x.Enviar("maria@example.com", It.Is<string>(s => s.Contains("Bem-vinda"))),
            Times.Once
        );
    }
}

Note como o teste é estruturado. Não estamos verificando um retorno; estamos verificando que o método Enviar foi chamado uma única vez com o email correto e uma mensagem contendo "Bem-vinda". Este é o comportamento esperado.

A implementação, mantendo a filosofia TDD, fica assim:

public class NotificacaoService
{
    private readonly IEmailService _emailService;

    public NotificacaoService(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public void GerarNotificacao(Usuario usuario)
    {
        _emailService.Enviar(usuario.Email, $"Bem-vinda, {usuario.Nome}!");
    }
}

public interface IEmailService
{
    void Enviar(string destinatario, string mensagem);
}

Outro Exemplo: Um Sistema de Logging

Imagine que você está desenvolvendo um serviço de processamento de pedidos. Quando um pedido é processado com sucesso, você quer registrar esse evento em um logger. Novamente, não há um output direto, mas há um comportamento esperado: o logger deve ser chamado com a mensagem correta.

[TestClass]
public class PedidoServiceTests
{
    [TestMethod]
    public void ProcessarPedido_ComSucesso_DeveRegistrarNoLog()
    {
        // Arrange
        var loggerMock = new Mock<ILogger>();
        var pedidoService = new PedidoService(loggerMock.Object);
        var pedido = new Pedido
        {
            Id = 123,
            Valor = 500.00m
        };

        // Act
        pedidoService.Processar(pedido);

        // Assert - Verificamos que o comportamento ocorreu
        loggerMock.Verify(
            x => x.Info(It.Is<string>(s => s.Contains("Pedido 123 processado"))),
            Times.Once
        );
    }
}

A implementação:

public class PedidoService
{
    private readonly ILogger _logger;

    public PedidoService(ILogger logger)
    {
        _logger = logger;
    }

    public void Processar(Pedido pedido)
    {
        // Lógica de processamento
        _logger.Info($"Pedido {pedido.Id} processado com sucesso");
    }
}

public interface ILogger
{
    void Info(string mensagem);
}

A chave está em usar Mocks e Stubs para verificar se as dependências foram acionadas corretamente. Você estabelece expectativas sobre como o código deve se comportar, não apenas sobre quais valores ele retorna. Isso é igualmente poderoso e segue os mesmos princípios do TDD.

Você ainda começa pelo fim. Você ainda sabe exatamente qual é o comportamento esperado antes de escrever uma única linha de código. Você ainda elimina a incerteza e constrói com propósito. A única diferença é que, em vez de afirmar um valor, você afirma que uma ação ocorreu.

Por Que o TDD Oferece Autonomia e Agilidade

O TDD oferece autonomia porque elimina a incerteza. Você sabe exatamente o que espera antes de começar. Isso reduz dramaticamente o retrabalho e os ciclos de correção desnecessários. Você não produz código desnecessário ou deixado de lado porque sempre está guiado por uma meta clara: satisfazer o teste.

Além disso, oferece agilidade. Ao iniciar pelos testes, você estabelece um contrato claro do que seu código deve fazer. Qualquer mudança futura é facilmente validada contra esses testes. A refatoração torna-se segura e rápida. Você pode mover-se com confiança, sabendo que se quebrar algo importante, os testes avisarão imediatamente.

O resultado final é um código limpo, objetivo e verdadeiramente testado. Não há código comentado desnecessário porque você não estava explorando caminhos incertos. Não há reescritas constantes porque você tinha clareza desde o início. Não há desperdício de tempo retestando manualmente a mesma funcionalidade várias vezes.

Conclusão: Mudando de Perspectiva

O TDD não é complicado. Na verdade, é elegantemente simples quando entendido através da metáfora certa. Trata-se de começar pelo final, de conhecer seu destino antes de traçar a rota. Trata-se de eliminar as múltiplas entradas possíveis de um labirinto e manter apenas o caminho que leva exatamente onde você precisa ir.

Seja testando outputs diretos como uma função de soma ou verificando comportamentos como a chamada de um serviço de email, os princípios são os mesmos. Você sempre começa pelo fim. Você sempre sabe o que espera. Você sempre constrói com propósito, não com incerteza.

Todos os desenvolvedores conhecem a importância dos testes, mas nem todos reconhecem o poder transformador de fazê-los em primeiro lugar. A autonomia e a agilidade que o TDD oferece não são luxo, são necessidade em um ambiente de desenvolvimento moderno. Ao adotar essa metodologia, você não apenas melhora a qualidade do seu código; você muda fundamentalmente a forma como trabalha, tornando-se mais confiante, mais produtivo e, acima de tudo, mais maestro do seu próprio destino no desenvolvimento.

Insights & Takeaways

  • O TDD inverte o fluxo tradicional de desenvolvimento, começando pelos testes ao invés de terminá-los, eliminando ciclos de tentativa e erro e reduzindo retrabalho significativo.
  • A metáfora do labirinto ilustra perfeitamente como o TDD nos ajuda a encontrar o caminho correto entre múltiplas soluções possíveis, começando pelo resultado esperado.
  • Ao definir o output esperado antes de escrever o código, você estabelece um contrato claro com seu sistema, garantindo que cada linha de código tenha propósito.
  • O TDD não se limita a verificar valores retornados; também permite testar comportamentos esperados através de Mocks e Stubs, mantendo a mesma filosofia de começar pelo fim.
  • A autonomia e agilidade ganhas com o TDD transformam a refatoração em um processo seguro e rápido, onde os testes validam instantaneamente qualquer mudança no código.

Comente no LinkedIn

A conversa sobre este artigo acontece no LinkedIn, onde ele também foi publicado. Discordou de algo, viu de outro jeito em produção? Conta lá.

Leia também