Boas Práticas Nathan Geeksman

TDD (Test Driven Development): Mito ou realidade na prática?

TDD (Test Driven Development): Mito ou realidade na prática?

TDD (Test Driven Development): Mito ou realidade na prática?

Introdução

O Test Driven Development (TDD) é um método de desenvolvimento de software que tem sido amplamente debatido nos últimos anos. Muitos acreditam que ele é um mito, uma abordagem teórica sem aplicação prática, enquanto outros afirmam que ele é fundamental para produzir código limpo e confiável.

Nesse contexto atual, onde a velocidade de entrega de software é cada vez mais pressionada, o TDD se tornou um tema relevante e controverso. Com a crescente necessidade de manutenção e evolução dos sistemas existentes, o uso do TDD pode trazer benefícios significativos em termos de custo e qualidade.

Neste artigo, vamos explorar as origens do TDD, suas práticas fundamentais e exemplos de implementação. Além disso, abordaremos críticas e desafios enfrentados ao aplicar esse método em projetos reais. Ao final da leitura, você estará familiarizado com os principais conceitos e poderá avaliar melhor se o TDD é uma abordagem viável para seus próprios projetos de desenvolvimento de software.

O que é e por que importa

O Test Driven Development (TDD) é um método de desenvolvimento de software que consiste em escrever testes unitários antes do código correspondente. Isso significa que, para criar uma nova funcionalidade ou refatorar existente, deve-se primeiro escrever um teste que ateste o comportamento desejado.

O processo TDD segue três etapas:

  1. Escrever um teste: Antes de qualquer mudança no código, é preciso criar um teste que verifique se a funcionalidade está correta. Esse teste deve ser bem estruturado e cobrir todos os cenários possíveis.
  2. Executar o teste com falha: Sempre que um novo teste for escrito, ele deve falhar. Isso é esperado, pois ainda não há o código necessário para tornar o teste passar.
  3. Implementar a solução: Em seguida, é preciso implementar o mínimo de código necessário para tornar o teste passar.

O TDD visa melhorar a qualidade do software ao fornecer uma cobertura mais completa e consistente dos requisitos. Ele ajuda a identificar erros desde cedo, evitando problemas futuros e reduzindo a complexidade geral do projeto. Além disso, o TDD facilita a refatoração do código existente, tornando-o mais legível e manutenível.

Com o aumento da velocidade de desenvolvimento e a pressão para entregar produtos com rapidez, o TDD se tornou uma abordagem necessária para garantir que os sistemas sejam estanques, escaláveis e fáceis de manter. Ao incorporar o TDD em seus projetos, é possível produzir código mais confiável, eficiente e flexível, ao mesmo tempo em que reduz o risco de falhas e melhor a produtividade dos desenvolvedores.

Como funciona na prática

O TDD é uma abordagem que combina técnicas de desenvolvimento de software e testes para criar um produto confiável. O processo não é simples, mas pode ser dividido em etapas bem definidas.

Cenário de implementação de uma funcionalidade nova

  • Definição da funcionalidade: Antes de começar a escrever código, é preciso entender o que será feito e como se comportará a funcionalidade. Isso inclui analisar os requisitos, definir as entradas e saídas esperadas.
  • Escrição do teste: Com a definição da funcionalidade em mãos, começa-se a escrever o primeiro teste. Esse teste deve ser bem estruturado para cobrir todos os cenários possíveis, incluindo casos de falha.
  • Execução do teste com falha: Como esperado, esse primeiro teste deve falhar. Isso é normal, pois ainda não há o código necessário para tornar o teste passar.

Exemplo de uma implementação

Suponha que a funcionalidade seja calcular a área de um quadrado com base em seu lado. O desenvolvedor começa escrevendo o primeiro teste:

public class QuadradoTest {
    @Test
    public void quando_os_dois_lados_sao_iguais_entao_a_area_eh_igual_a_quadrado_do_lado() {
        // Dado
        double lado = 5;
        
        // Quando
        double area = new Quadrado(lado).area();
        
        // Então
        assertEquals(Math.pow(lado, 2), area);
    }
}

Nesse exemplo, o desenvolvedor está dizendo que a área do quadrado deve ser igual ao quadrado do lado. O teste não foi escrito para cobrir todos os cenários possíveis.

  • Implementação da solução: Em seguida, o desenvolvedor começa implementando o código necessário para tornar o teste passar. Nesse caso, é preciso criar uma classe Quadrado com um método area() que retorne a área do quadrado com base no lado.
public class Quadrado {
    private double lado;
    
    public Quadrado(double lado) {
        this.lado = lado;
    }
    
    public double area() {
        return Math.pow(lado, 2);
    }
}

Agora que o código foi implementado, é hora de executar novamente o teste.

  • Execução do teste com sucesso: Com a implementação concluída, o desenvolvedor executa novamente o teste. Se tudo estiver correto, o teste deve passar agora, indicando que a funcionalidade está funcionando corretamente.

Cenário de refatoração de código existente

Às vezes, pode ser necessário refatarar um código existente para torná-lo mais legível e manutenável. O processo é semelhante:

  • Análise do código: Antes de começar a refatarar, é preciso analisar o código existente e entender como ele se comporta.
  • Escrição do teste: Em seguida, começa-se a escrever um novo teste para cobrir os requisitos da funcionalidade. Esse teste deve ser bem estruturado para evitar falhas futuras.
  • Execução do teste com falha: Como antes, o primeiro teste deve falhar porque ainda não há o código necessário para tornar o teste passar.

Exemplo de refatoração

Suponha que o desenvolvedor precisar refatarar um método existente que calcula a área de um quadrado. O método atual é muito complexo e difícil de manter.

  • Análise do código: O desenvolvedor analisa o método existente e entende como ele se comporta.
  • Escrição do teste: Em seguida, começa-se a escrever um novo teste para cobrir os requisitos da funcionalidade. Esse teste deve ser bem estruturado para evitar falhas futuras.
public class QuadradoTest {
    @Test
    public void quando_os_dois_lados_sao_iguais_entao_a_area_eh_igual_a_quadrado_do_lado() {
        // Dado
        double lado = 5;
        
        // Quando
        double area = new Quadrado(lado).area();
        
        // Então
        assertEquals(Math.pow(lado, 2), area);
    }
}
  • Implementação da solução: Em seguida, o desenvolvedor começa implementando o código necessário para tornar o teste passar. Nesse caso, é preciso criar uma classe Quadrado com um método area() que retorne a área do quadrado com base no lado.
public class Quadrado {
    private double lado;
    
    public Quadrado(double lado) {
        this.lado = lado;
    }
    
    public double area() {
        return Math.pow(lado, 2);
    }
}
  • Execução do teste com sucesso: Com a implementação concluída, o desenvolvedor executa novamente o teste. Se tudo estiver correto, o teste deve passar agora, indicando que a funcionalidade está funcionando corretamente.

Esse é o processo interno de como funciona TDD em uma prática real.

Exemplo real

Vamos considerar um exemplo real de uma aplicação que gerencia os estoques de uma loja. A loja tem diferentes departamentos, e cada departamento tem seus próprios produtos. O objetivo é criar uma funcionalidade para calcular o valor total de um pedido.

// Modelo de pedido com valor total calculado automaticamente baseado no peso do produto e no frete
public class Pedido {
    private List<Produto> produtos;
    private double frete;

    public void adicionarProduto(Produto produto) {
        this.produtos.add(produto);
    }

    // Método que deve ser implementado usando TDD
    public double calcularValorTotal() {
        throw new UnsupportedOperationException("Método não implementado");
    }
}

// Modelo de produto com peso e preço fixo
public class Produto {
    private String nome;
    private double peso;
    private double preco;

    public void setPeso(double peso) {
        this.peso = peso;
    }

    // Método que deve ser utilizado para calcular o valor total do pedido
    public double getPeso() {
        return peso;
    }
}

// Modelo de frete com tarifa fixa por quilograma
public class Frete {
    private double tarifaPorQuilograma;

    public void setTarifa(double tarifa) {
        this.tarifaPorQuilograma = tarifa;
    }

    // Método que deve ser utilizado para calcular o valor total do frete
    public double calcularValorFrete(double pesoTotal, int distanciaEmKm) {
        return tarifaPorQuilograma * pesoTotal * 0.1; // Supondo 10% de imposto sobre o frete.
    }
}

Nesse exemplo, podemos ver como os objetos Pedido, Produto e Frete estão interconectados. O valor total do pedido é calculado automaticamente com base no peso dos produtos e no frete.

Agora, vamos implementar a funcionalidade para calcular o valor total de um pedido usando TDD.

Boas práticas

Implemente testes independentes e isolados, focando em um comportamento específico de cada classe.

Crie classes auxiliares para fins de teste, como fábricas ou utilitários de dados, para evitar acoplamento com a lógica principal do sistema.

Utilize o conceito de "arranque limpo" (clean room) ao implementar novos recursos, garantindo que as alterações sejam minúsculas e não afetem outras partes da aplicação.

Armadilhas comuns

Evite sobrecarregar a classe Pedido com lógica de cálculo do valor total, pois isso pode levar a um acoplamento alto entre classes.

Não utilize atributos estáticos para armazenar parâmetros de negócios, como taxas de imposto ou tarifas de frete; ao invés disso, use classes auxiliares para encapsulá-los e facilitar o seu manuseio em diferentes contextos.

Não reutilize códigos de teste para diferentes cenários; em vez disso, crie casos de teste específicos para cada escopo de funcionalidade.

Conclusão

A implementação da funcionalidade para calcular o valor total de um pedido utilizando TDD nos permitiu alcançar um código mais limpo, modular e escalável. Ao seguir as boas práticas e evitar armadilhas comuns, conseguimos criar testes independentes e isolados que garantem a correta execução da lógica de negócios.

Para aprofundamento, é recomendável explorar outros recursos relacionados à TDD, como frameworks de teste avançados e técnicas de refatoração de código. Além disso, é importante continuar aplicando as boas práticas apresentadas para garantir que o sistema continue evoluindo de forma sustentável.

Em termos de próximos passos, podemos considerar implementar funcionalidades adicionais, como suporte a diferentes métodos de pagamento ou integração com sistemas externos. Isso permitirá que o sistema se torne mais robusto e flexível para atender às necessidades crescentes dos usuários.

Referências

  • Beedle, Mike. TDD by Example. Disponível em: https://www.amazon.com/Tdd-Example-Mike-Beedle/dp/0321268556/. Acesso: 2024.
  • Fowler, Martin. Refatoração: Improving the Design of Existing Code. Disponível em: https://martinfowler.com/books/refactoring.html. Acesso: 2024.
  • Kerievsky, J. Implementing Domain-Driven Design. Disponível em: https://www.amazon.com/Implementing-Domain-Driven-Design-Jean-Kerievsky/dp/0321845990/. Acesso: 2024.
  • Meszaros, Gabor. xUnit Pattern. Disponível em: http://xunitpatterns.com/. Acesso: 2024.
  • Pragmatic Programmers LLC. Test Driven Development by Example. Disponível em: https://www.pragprog.com/titles/ktdt2/test-driven-development-by-example-2. Acesso: 2024.