Database
 sql >> Base de Dados >  >> RDS >> Database

Regras para implementação de TDD em projeto antigo


O artigo “Sliding Responsibility of the Repository Pattern” levantou várias questões, que são muito difíceis de responder. Precisamos de um repositório se a completa desconsideração dos detalhes técnicos é impossível? Quão complexo deve ser o repositório para que sua adição possa ser considerada válida? A resposta a essas perguntas varia de acordo com a ênfase colocada no desenvolvimento de sistemas. Provavelmente a pergunta mais difícil é a seguinte:você ainda precisa de um repositório? O problema da “abstração fluida” e a crescente complexidade da codificação com o aumento do nível de abstração não permitem encontrar uma solução que satisfaça os dois lados da cerca. Por exemplo, em relatórios, o design de intenção leva à criação de um grande número de métodos para cada filtro e classificação, e uma solução genérica cria uma grande sobrecarga de codificação.



Para ter uma visão completa, analisei o problema das abstrações em termos de sua aplicação em um código legado. Um repositório, neste caso, nos interessa apenas como ferramenta para obter código de qualidade e sem bugs. É claro que esse padrão não é a única coisa necessária para a aplicação das práticas de TDD. Tendo comido um alqueire de sal durante o desenvolvimento de vários grandes projetos e observando o que funciona e o que não funciona, desenvolvi algumas regras para mim que me ajudam a seguir as práticas de TDD. Estou aberto a críticas construtivas e outros métodos de implementação do TDD.

Prólogo


Alguns podem notar que não é possível aplicar TDD em um projeto antigo. Há uma opinião de que diferentes tipos de testes de integração (testes de interface do usuário, de ponta a ponta) são mais adequados para eles porque é muito difícil entender o código antigo. Além disso, você pode ouvir que escrever testes antes da codificação real leva apenas a uma perda de tempo, porque podemos não saber como o código funcionará. Tive que trabalhar em vários projetos, onde fiquei limitado apenas a testes de integração, acreditando que testes unitários não são indicativos. Ao mesmo tempo, muitos testes foram escritos, muitos serviços executados, etc. Como resultado, apenas uma pessoa poderia entendê-los, que, de fato, os escreveu.

Durante minha prática, consegui trabalhar em vários projetos muito grandes, onde havia muito código legado. Alguns deles apresentavam testes, outros não (havia apenas a intenção de implementá-los). Participei de dois grandes projetos, nos quais de alguma forma tentei aplicar a abordagem TDD. No estágio inicial, o TDD foi percebido como um desenvolvimento do Test First. Eventualmente, as diferenças entre esse entendimento simplificado e a percepção atual, abreviadamente chamada de BDD, tornaram-se mais claras. Qualquer que seja a linguagem usada, os pontos principais, que chamo de regras, permanecem semelhantes. Alguém pode encontrar paralelos entre as regras e outros princípios de escrever um bom código.

Regra 1:usando de baixo para cima (de dentro para fora)


Esta regra refere-se ao método de análise e design de software ao incorporar novos pedaços de código em um projeto de trabalho.

Quando você está projetando um novo projeto, é absolutamente natural imaginar um sistema inteiro. Nesse estágio, você controla o conjunto de componentes e a flexibilidade futura da arquitetura. Portanto, você pode escrever módulos que podem ser integrados de forma fácil e intuitiva entre si. Essa abordagem Top-Down permite que você execute um bom projeto inicial da arquitetura futura, descreva as linhas de orientação necessárias e tenha uma visão completa do que, no final, você deseja. Depois de um tempo, o projeto se transforma no que é chamado de código legado. E então a diversão começa.

No estágio em que é necessário incorporar uma nova funcionalidade em um projeto existente com vários módulos e dependências entre eles, pode ser muito difícil colocá-los todos na cabeça para fazer o design adequado. O outro lado desse problema é a quantidade de trabalho necessária para realizar essa tarefa. Portanto, a abordagem de baixo para cima será mais eficaz neste caso. Em outras palavras, primeiro você cria um módulo completo que resolve a tarefa necessária e, em seguida, o integra no sistema existente, fazendo apenas as alterações necessárias. Neste caso, você pode garantir a qualidade deste módulo, pois é uma unidade completa do funcional.

Deve-se notar que não é tão simples com as abordagens. Por exemplo, ao projetar uma nova funcionalidade em um sistema antigo, você, goste ou não, usará as duas abordagens. Durante a análise inicial, você ainda precisa avaliar o sistema, depois reduzi-lo ao nível do módulo, implementá-lo e depois voltar ao nível de todo o sistema. Na minha opinião, o principal aqui é não esquecer que o novo módulo deve ser uma funcionalidade completa e independente, como uma ferramenta separada. Quanto mais estritamente você aderir a essa abordagem, menos alterações serão feitas no código antigo.

Regra 2:teste apenas o código modificado


Ao trabalhar com um projeto antigo, não há absolutamente nenhuma necessidade de escrever testes para todos os cenários possíveis do método/classe. Além disso, você pode não estar ciente de alguns cenários, pois pode haver muitos deles. O projeto já está em produção, o cliente está satisfeito, então você pode relaxar. Em geral, apenas suas alterações causam problemas neste sistema. Portanto, apenas eles devem ser testados.

Exemplo

Existe um módulo de loja online, que cria um carrinho de itens selecionados e o armazena em um banco de dados. Não nos importamos com a implementação específica. Feito como feito – este é o código legado. Agora precisamos introduzir um novo comportamento aqui:enviar uma notificação ao departamento de contabilidade caso o custo do carrinho ultrapasse $ 1.000. Aqui está o código que vemos. Como introduzir a mudança?
public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);
        SaveToDb(cart);
    }
}

De acordo com a primeira regra, as mudanças devem ser mínimas e atômicas. Não estamos interessados ​​no carregamento de dados, não nos preocupamos com o cálculo de impostos e salvamento no banco de dados. Mas estamos interessados ​​no carrinho calculado. Se houvesse um módulo que fizesse o que é necessário, ele executaria a tarefa necessária. É por isso que fazemos isso.
public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        var items = LoadSelectedItemsFromDb();
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        // NEW FEATURE
        new EuropeShopNotifier().Send(cart);

        SaveToDb(cart);
    }
}

Esse notificador opera por conta própria, pode ser testado e as alterações feitas no código antigo são mínimas. Isso é exatamente o que diz a segunda regra.

Regra 3:testamos apenas os requisitos


Para se livrar do número de cenários que exigem testes com testes de unidade, pense no que você realmente precisa de um módulo. Escreva primeiro para o conjunto mínimo de condições que você pode imaginar como requisitos para o módulo. O conjunto mínimo é o conjunto, que quando complementado com um novo, o comportamento do módulo não muda muito, e quando removido, o módulo não funciona. A abordagem BDD ajuda muito neste caso.

Além disso, imagine como outras classes que são clientes do seu módulo irão interagir com ele. Você precisa escrever 10 linhas de código para configurar seu módulo? Quanto mais simples for a comunicação entre as partes do sistema, melhor. Portanto, é melhor selecionar módulos responsáveis ​​por algo específico do código antigo. SOLID virá para ajudar neste caso.

Exemplo

Agora vamos ver como tudo descrito acima nos ajudará com o código. Primeiro, selecione todos os módulos que estão associados apenas indiretamente à criação do carrinho. É assim que se distribui a responsabilidade pelos módulos.
public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) load from DB
        var items = LoadSelectedItemsFromDb();

        // 2) Tax-object creates SaleItem and
        // 4) goes through items and apply taxes
        var taxes = new EuropeTaxes();
        var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList();

        // 3) creates a cart and 4) applies taxes
        var cart = new Cart();
        cart.Add(saleItems);
        taxes.ApplyTaxes(cart);

        new EuropeShopNotifier().Send(cart);

        // 4) store to DB
        SaveToDb(cart);
    }
}

Assim podem ser distinguidos. É claro que tais mudanças não podem ser feitas de uma só vez em um grande sistema, mas podem ser feitas gradualmente. Por exemplo, quando as alterações estão relacionadas a um módulo de imposto, você pode simplificar como outras partes do sistema dependem dele. Isso pode ajudar a se livrar de altas dependências e usá-lo no futuro como uma ferramenta independente.
public class EuropeShop : Shop
{
    public override void CreateSale()
    {
        // 1) extracted to a repository
        var itemsRepository = new ItemsRepository();
        var items = itemsRepository.LoadSelectedItems();
			
        // 2) extracted to a mapper
        var saleItems = items.ConvertToSaleItems();
			
        // 3) still creates a cart
        var cart = new Cart();
        cart.Add(saleItems);
			
        // 4) all routines to apply taxes are extracted to the Tax-object
        new EuropeTaxes().ApplyTaxes(cart);
			
        new EuropeShopNotifier().Send(cart);
			
        // 5) extracted to a repository
        itemsRepository.Save(cart);
    }
}

Quanto aos testes, esses cenários serão suficientes. Até agora, sua implementação não nos interessa.
public class EuropeTaxesTests
{
    public void Should_not_fail_for_null() { }

    public void Should_apply_taxes_to_items() { }

    public void Should_apply_taxes_to_whole_cart() { }

    public void Should_apply_taxes_to_whole_cart_and_change_items() { }
}

public class EuropeShopNotifierTests
{
    public void Should_not_send_when_less_or_equals_to_1000() { }

    public void Should_send_when_greater_than_1000() { }

    public void Should_raise_exception_when_cannot_send() { }
}

Regra 4:adicione apenas código testado


Como escrevi anteriormente, você deve minimizar as alterações no código antigo. Para fazer isso, o código antigo e o novo/modificado podem ser divididos. O novo código pode ser colocado em métodos que podem ser verificados usando testes de unidade. Essa abordagem ajudará a reduzir os riscos associados. Existem duas técnicas que foram descritas no livro “Working Effectively with Legacy Code” (link para o livro abaixo).

Método/classe do Sprout – essa técnica permite que você incorpore um novo código muito seguro em um antigo. A maneira como adicionei o notificador é um exemplo dessa abordagem.

Método Wrap – um pouco mais complicado, mas a essência é a mesma. Nem sempre funciona, mas apenas nos casos em que um novo código é chamado antes/depois de um antigo. Ao atribuir responsabilidades, duas chamadas do método ApplyTaxes foram substituídas por uma chamada. Para isso, foi necessário alterar o segundo método para que a lógica não quebrasse muito e pudesse ser verificada. Era assim que a classe era antes das mudanças.
public class EuropeTaxes : Taxes
{
    internal override SaleItem ApplyTaxes(Item item)
    {
        var saleItem = new SaleItem(item)
        {
            SalePrice = item.Price*1.2m
        };
        return saleItem;
    }

    internal override void ApplyTaxes(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m/cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

E aqui como fica. A lógica de trabalhar com os elementos do carrinho mudou um pouco, mas no geral, tudo permaneceu igual. Nesse caso, o método antigo chama primeiro um novo ApplyToItems e, em seguida, sua versão anterior. Esta é a essência desta técnica.
public class EuropeTaxes : Taxes
{
    internal override void ApplyTaxes(Cart cart)
    {
        ApplyToItems(cart);
        ApplyToCart(cart);
    }

    private void ApplyToItems(Cart cart)
    {
        foreach (var item in cart.SaleItems)
            item.SalePrice = item.Price*1.2m;
    }

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return;
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

Regra 5:"quebrar" dependências ocultas


Esta é a regra sobre o maior mal em um código antigo:o uso do novo operador dentro do método de um objeto para criar outros objetos, repositórios ou outros objetos complexos. Por que isso é ruim? A explicação mais simples é que isso torna as partes do sistema altamente conectadas e ajuda a reduzir sua coerência. Ainda mais curto:leva à violação do princípio “baixo acoplamento, alta coesão”. Se você olhar para o outro lado, esse código é muito difícil de extrair em uma ferramenta separada e independente. Livrar-se dessas dependências ocultas de uma só vez é muito trabalhoso. Mas isso pode ser feito gradualmente.

Primeiro, você deve transferir a inicialização de todas as dependências para o construtor. Em particular, isso se aplica ao novo operadores e a criação de classes. Se você tiver ServiceLocator para obter instâncias de classes, também deverá removê-lo para o construtor, onde poderá extrair todas as interfaces necessárias dele.

Em segundo lugar, as variáveis ​​que armazenam a instância de um objeto/repositório externo devem ter um tipo abstrato e melhor uma interface. A interface é melhor porque fornece mais recursos para um desenvolvedor. Como resultado, isso permitirá fazer uma ferramenta atômica de um módulo.

Em terceiro lugar, não deixe folhas de método grandes. Isso mostra claramente que o método faz mais do que está especificado em seu nome. Também é indicativo de uma possível violação do SOLID, a Lei de Demeter.

Exemplo

Agora vamos ver como o código que cria o carrinho foi alterado. Apenas o bloco de código que cria o carrinho permaneceu inalterado. O restante foi colocado em classes externas e pode ser substituído por qualquer implementação. Agora a classe EuropeShop assume a forma de uma ferramenta atômica que precisa de certas coisas que são explicitamente representadas no construtor. O código torna-se mais fácil de perceber.
public class EuropeShop : Shop
{
    private readonly IItemsRepository _itemsRepository;
    private readonly Taxes.Taxes _europeTaxes;
    private readonly INotifier _europeShopNotifier;

    public EuropeShop()
    {
        _itemsRepository = new ItemsRepository();
        _europeTaxes = new EuropeTaxes();
        _europeShopNotifier = new EuropeShopNotifier();
    }

    public override void CreateSale()
    {
        var items = _itemsRepository.LoadSelectedItems();
        var saleItems = items.ConvertToSaleItems();

        var cart = new Cart();
        cart.Add(saleItems);

        _europeTaxes.ApplyTaxes(cart);
        _europeShopNotifier.Send(cart);
        _itemsRepository.Save(cart);
    }
}SCRIPT

Regra 6:quanto menos testes grandes, melhor


Os grandes testes são diferentes testes de integração que tentam testar scripts de usuário. Sem dúvida, eles são importantes, mas verificar a lógica de algum IF na profundidade do código é muito caro. Escrever este teste leva o mesmo tempo, se não mais, que escrever a própria funcionalidade. Apoiá-los é como outro código legado, que é difícil de mudar. Mas são apenas testes!

É necessário entender quais testes são necessários e aderir claramente a esse entendimento. Se você precisar de uma verificação de integração, escreva um conjunto mínimo de testes, incluindo cenários de interação positivos e negativos. Se você precisar testar o algoritmo, escreva um conjunto mínimo de testes de unidade.

Regra 7:não teste métodos privados


Um método privado pode ser muito complexo ou conter código que não é chamado de métodos públicos. Tenho certeza de que qualquer outro motivo que você possa imaginar provará ser uma característica de um código ou design “ruim”. Muito provavelmente, uma parte do código do método privado deve se tornar um método/classe separado. Verifique se o primeiro princípio de SOLID foi violado. Esta é a primeira razão pela qual não vale a pena fazê-lo. A segunda é que dessa forma você verifica não o comportamento de todo o módulo, mas como o módulo o implementa. A implementação interna pode mudar independentemente do comportamento do módulo. Portanto, neste caso, você obtém testes frágeis e leva mais tempo do que o necessário para suportá-los.

Para evitar a necessidade de testar métodos privados, apresente suas classes como um conjunto de ferramentas atômicas e você não sabe como elas são implementadas. Você espera algum comportamento que está testando. Essa atitude também se aplica a classes no contexto da montagem. As classes que estão disponíveis para os clientes (de outros assemblies) serão públicas, e aquelas que realizam trabalho interno – privadas. Embora, há uma diferença de métodos. Classes internas podem ser complexas, então elas podem ser transformadas em internas e também testadas.

Exemplo

Por exemplo, para testar uma condição no método privado da classe EuropeTaxes, não escreverei um teste para este método. Eu espero que os impostos sejam aplicados de uma certa forma, então o teste vai refletir esse mesmo comportamento. No teste, contei manualmente qual deveria ser o resultado, tomei como padrão e espero o mesmo resultado da turma.
public class EuropeTaxes : Taxes
{
    // code skipped

    private void ApplyToCart(Cart cart)
    {
        if (cart.TotalSalePrice <= 300m) return; // <<< I WANT TO TEST THIS CONDIFTION
        var exclusion = 30m / cart.SaleItems.Count;
        foreach (var item in cart.SaleItems)
            if (item.SalePrice - exclusion > 100m)
                item.SalePrice -= exclusion;
    }
}

// test suite
public class EuropeTaxesTests
{
    // code skipped

    [Fact]
    public void Should_apply_taxes_to_cart_greater_300()
    {
        #region arrange
        // list of items which will create a cart greater 300
        var saleItems = new List<Item>(new[]{new Item {Price = 83.34m},
            new Item {Price = 83.34m},new Item {Price = 83.34m}})
            .ConvertToSaleItems();
        var cart = new Cart();
        cart.Add(saleItems);

        const decimal expected = 83.34m*3*1.2m;
        #endregion

        // act
        new EuropeTaxes().ApplyTaxes(cart);

        // assert
        Assert.Equal(expected, cart.TotalSalePrice);
    }
}

Regra 8:Não teste o algoritmo dos métodos


Algumas pessoas verificam o número de chamadas de determinados métodos, verificam a própria chamada etc., em outras palavras, verificam o trabalho interno dos métodos. É tão ruim quanto testar os privados. A diferença está apenas na camada de aplicação de tal verificação. Essa abordagem novamente oferece muitos testes frágeis, portanto, algumas pessoas não aceitam o TDD corretamente.

Consulte Mais informação…

Regra 9:não modifique o código legado sem testes


Esta é a regra mais importante porque reflete o desejo da equipe de seguir esse caminho. Sem o desejo de avançar nessa direção, tudo o que foi dito acima não tem um significado especial. Porque se um desenvolvedor não quer usar o TDD (não entende seu significado, não vê os benefícios, etc.), então seu real benefício será obscurecido pela discussão constante sobre o quão difícil e ineficiente ele é.

Se você for usar o TDD, discuta isso com sua equipe, adicione-o à Definição de Pronto e aplique-o. No começo, será difícil, como com tudo que é novo. Como qualquer arte, o TDD requer prática constante e o prazer vem à medida que você aprende. Gradualmente, haverá mais testes de unidade escritos, você começará a sentir a “saúde” do seu sistema e começará a apreciar a simplicidade de escrever código, descrevendo os requisitos no primeiro estágio. Existem estudos de TDD realizados em grandes projetos reais na Microsoft e IBM, mostrando uma redução de bugs em sistemas de produção de 40% para 80% (veja os links abaixo).

Leitura adicional
  1. Livro “Trabalhando de forma eficaz com código legado”, de Michael Feathers
  2. TDD até o pescoço no código legado
  3. Quebrando dependências ocultas
  4. O ciclo de vida do código legado
  5. Você deve testar métodos privados em uma classe?
  6. Teste de unidade interno
  7. 5 equívocos comuns sobre TDD e testes unitários
  8. Lei de Deméter