Quais são os princípios de design que promovem código testável? (projetar código testável x direcionar design através de testes)


54

A maioria dos projetos em que trabalho considera o desenvolvimento e o teste de unidade isoladamente, o que faz com que escrever testes de unidade posteriormente seja um pesadelo. Meu objetivo é manter os testes em mente durante as fases de design de alto e baixo nível.

Quero saber se existem princípios de design bem definidos que promovam código testável. Um desses princípios que eu acabei de entender recentemente é a Inversão de Dependência por Injeção de Dependência e Inversão de Controle.

Eu li que existe algo conhecido como SOLID. Quero entender se o cumprimento dos princípios do SOLID resulta indiretamente em um código facilmente testável? Caso contrário, existem princípios de design bem definidos que promovam código testável?

Estou ciente de que existe algo conhecido como Desenvolvimento Orientado a Testes. No entanto, estou mais interessado em projetar código com testes em mente durante a fase de design em si do que em conduzi-lo através de testes. Espero que isto faça sentido.

Mais uma pergunta relacionada a este tópico é se está correto redimensionar um produto / projeto existente e fazer alterações no código e no design com o objetivo de poder escrever um caso de teste de unidade para cada módulo?



Obrigado. Eu apenas comecei a ler o artigo e já faz sentido.

11
Esta é uma das minhas perguntas da entrevista ("Como você cria código para ser facilmente testado em unidade?"). Ele mostra-me sozinho se eles entendem testes de unidade, zombaria / stubbing, OOD e potencialmente TDD. Infelizmente, as respostas geralmente são algo como "Criar um banco de dados de teste".
precisa

Respostas:


56

Sim, o SOLID é uma maneira muito boa de criar código que pode ser facilmente testado. Como uma cartilha curta:

S - Princípio da responsabilidade única: um objeto deve fazer exatamente uma coisa e deve ser o único objeto na base de código que faz essa coisa. Por exemplo, pegue uma classe de domínio, por exemplo, uma fatura. A classe Fatura deve representar a estrutura de dados e as regras de negócios de uma fatura, conforme usado no sistema. Deve ser a única classe que representa uma fatura na base de código. Isso pode ser dividido em detalhes para dizer que um método deve ter um propósito e deve ser o único método na base de código que atende a essa necessidade.

Seguindo esse princípio, você aumenta a capacidade de teste do seu design, diminuindo o número de testes que precisa escrever para testar a mesma funcionalidade em objetos diferentes e, geralmente, também acaba com partes menores de funcionalidade que são mais fáceis de testar isoladamente.

O - Princípio Aberto / Fechado: Uma classe deve ser aberta para extensão, mas fechada para alteração . Uma vez que um objeto exista e funcione corretamente, o ideal é que não haja necessidade de voltar ao objeto para fazer alterações que adicionem novas funcionalidades. Em vez disso, o objeto deve ser estendido, derivando-o ou conectando implementações de dependência novas ou diferentes a ele, para fornecer essa nova funcionalidade. Isso evita regressão; você pode introduzir a nova funcionalidade quando e onde for necessária, sem alterar o comportamento do objeto, pois ele já é usado em outro lugar.

Ao seguir esse princípio, você geralmente aumenta a capacidade do código de tolerar "zombarias" e também evita a necessidade de reescrever testes para antecipar um novo comportamento; todos os testes existentes para um objeto ainda devem funcionar na implementação não estendida, enquanto novos testes para novas funcionalidades usando a implementação estendida também devem funcionar.

L - Princípio de Substituição de Liskov: Uma classe A, dependente da classe B, deve poder usar qualquer X: B sem saber a diferença. Isso basicamente significa que qualquer coisa que você use como dependência deve ter um comportamento semelhante ao da classe dependente. Como um pequeno exemplo, digamos que você tenha uma interface IWriter que expõe Write (string), implementada pelo ConsoleWriter. Agora você precisa gravar em um arquivo e criar o FileWriter. Ao fazer isso, você deve garantir que o FileWriter possa ser usado da mesma maneira que o ConsoleWriter (o que significa que a única maneira pela qual o dependente pode interagir com ele é chamando Write (string)) e, portanto, informações adicionais que o FileWriter talvez precise fazer isso O trabalho (como o caminho e o arquivo no qual gravar) deve ser fornecido de outro lugar que não o dependente.

Isso é imenso para escrever código testável, porque um design que esteja em conformidade com o LSP pode ter um objeto "zombado" substituído pela coisa real a qualquer momento sem alterar o comportamento esperado, permitindo que pequenos pedaços de código sejam testados isoladamente com confiança. que o sistema funcionará com os objetos reais conectados.

I - Princípio de Segregação de Interface: Uma interface deve ter o mínimo de métodos possível para fornecer a funcionalidade da função definida pela interface . Simplificando, interfaces menores são melhores que menos interfaces maiores. Isso ocorre porque uma interface grande tem mais motivos para mudar e causa mais alterações em outros lugares da base de código que podem não ser necessárias.

A adesão ao ISP melhora a testabilidade, reduzindo a complexidade dos sistemas em teste e as dependências desses SUTs. Se o objeto que você está testando depende de uma interface IDoThreeThings que expõe DoOne (), DoTwo () e DoThree (), você deve simular um objeto que implemente todos os três métodos, mesmo que o objeto use apenas o método DoTwo. Porém, se o objeto depender apenas do IDoTwo (que expõe apenas o DoTwo), você poderá zombar mais facilmente de um objeto que possua esse método.

D - Princípio da Inversão da Dependência: Concretizações e abstrações nunca devem depender de outras concretizações, mas de abstrações . Esse princípio reforça diretamente o princípio do acoplamento flexível. Um objeto nunca deve saber o que é um objeto; em vez disso, deveria se importar com o que um objeto FAZ. Portanto, o uso de interfaces e / ou classes básicas abstratas sempre deve ser preferido sobre o uso de implementações concretas ao definir propriedades e parâmetros de um objeto ou método. Isso permite que você troque uma implementação por outra sem precisar alterar o uso (se você também seguir o LSP, que acompanha o DIP).

Novamente, isso é imenso para a testabilidade, pois permite, mais uma vez, injetar uma implementação simulada de uma dependência em vez de uma implementação de "produção" no objeto que está sendo testado, enquanto ainda testa o objeto na forma exata que ele terá enquanto em produção. Essa é a chave para o teste de unidade "isoladamente".


16

Eu li que existe algo conhecido como SOLID. Quero entender se o cumprimento dos princípios do SOLID resulta indiretamente em um código facilmente testável?

Se aplicado corretamente, sim. Há uma postagem no blog de Jeff explicando os princípios do SOLID de uma maneira muito curta (também vale a pena ouvir o podcast mencionado), sugiro que você dê uma olhada lá se descrições mais longas estiverem jogando você fora.

Pela minha experiência, dois princípios do SOLID desempenham um papel importante no design de código testável:

  • Princípio de segregação de interface - você deve preferir muitas interfaces específicas do cliente em vez de menos interfaces de uso geral. Isso combina com o Princípio de Responsabilidade Única e ajuda a projetar classes orientadas a recursos / tarefas, que, em troca, são muito mais fáceis de testar (em comparação com as mais gerais, ou com frequência "gerentes" e "contextos" abusados ) - menos dependências , menos complexidade, testes mais refinados e óbvios. Em resumo, pequenos componentes levam a testes simples.
  • Princípio de inversão de dependência - projeto por contrato, não por implementação. Isso beneficiará você ao testar objetos complexos e ao perceber que não precisa de um gráfico inteiro de dependências apenas para configurá-lo , mas você pode simplesmente zombar da interface e concluir o processo.

Acredito que esses dois ajudarão você mais ao projetar para testabilidade. Os restantes também têm um impacto, mas eu diria que não tão grande.

(...) se está correto redefinir um produto / projeto existente e fazer alterações no código e no design para poder escrever um caso de teste de unidade para cada módulo?

Sem testes de unidade existentes, é simplesmente colocado - pedindo problemas. Teste de unidade é sua garantia de que seu código funciona . A introdução de alterações de última hora será identificada imediatamente se você tiver uma cobertura de testes adequada.

Agora, se você deseja alterar o código existente para adicionar testes de unidade , isso apresenta uma lacuna na qual você ainda não possui testes, mas já alterou o código . Naturalmente, você pode não ter idéia do que suas alterações foram quebradas. Esta é a situação que você deseja evitar.

De qualquer forma, vale a pena escrever testes de unidade, mesmo com códigos difíceis de testar. Se o seu código está funcionando , mas não é unidade testada, solução adequada seria a de testes de gravação para ele e , em seguida, introduzir alterações. No entanto, observe que alterar o código testado para torná-lo mais facilmente testável é algo em que sua gerência pode não querer gastar dinheiro (provavelmente você ouvirá que ele traz pouco ou nenhum valor comercial).


iaw alta coesão e baixo acoplamento
jk.

8

SUA PRIMEIRA PERGUNTA:

O SOLID é realmente o caminho a percorrer. Acho que os dois aspectos mais importantes da sigla SOLID, quando se trata de testabilidade, são o S (responsabilidade única) e o D (injeção de dependência).

Responsabilidade Única : Suas aulas devem realmente fazer apenas uma coisa e apenas uma coisa. uma classe que cria um arquivo, analisa alguma entrada e a grava no arquivo já está fazendo três coisas. Se sua turma faz apenas uma coisa, você sabe exatamente o que esperar dela, e projetar os casos de teste para isso deve ser bastante fácil.

Injeção de Dependência (DI): Isso permite que você controle o ambiente de teste. Em vez de criar objetos estrangeiros dentro do seu código, você o injeta através do construtor da classe ou da chamada do método. Ao desestabilizar, você simplesmente substitui classes reais por stubs ou zombarias, que você controla inteiramente.

SUA SEGUNDA PERGUNTA: Idealmente, você escreve testes que documentam o funcionamento do seu código antes de refatorá-lo. Dessa forma, você pode documentar que sua refatoração reproduz os mesmos resultados que o código original. No entanto, seu problema é que o código de funcionamento é difícil de testar. Esta é uma situação clássica! Meu conselho é: pense com cuidado na refatoração antes do teste de unidade. Se você puder; escreva testes para o código de trabalho, refatorar o código e refatorar os testes. Sei que custará horas, mas você terá mais certeza de que o código refatorado faz o mesmo que o antigo. Dito isto, desisti muitas vezes. As aulas podem ser tão feias e confusas que uma reescrita é a única maneira de torná-las testáveis.


4

Além das outras respostas, que se concentram em obter um acoplamento flexível, eu gostaria de dizer uma palavra sobre o teste de lógica complicada.

Certa vez, tive que testar por unidade uma classe cuja lógica era complexa, com muitos condicionais e onde era difícil entender o papel dos campos.

Substituí esse código por muitas classes pequenas que representam uma máquina de estado . A lógica tornou-se muito mais simples de seguir, uma vez que os diferentes estados da classe anterior se tornaram explícitos. Cada classe estadual era independente das outras e, portanto, eram facilmente testáveis.

O fato de os estados serem explícitos tornou mais fácil enumerar todos os caminhos possíveis do código (as transições de estado) e, assim, escrever um teste de unidade para cada um.

Obviamente, nem toda lógica complexa pode ser modelada como uma máquina de estado.


3

O SOLID é um excelente começo, na minha experiência, quatro dos aspectos do SOLID realmente funcionam bem com testes de unidade.

  • Princípio de responsabilidade única - cada classe faz uma coisa e apenas uma coisa. Calculando um valor, abrindo um arquivo, analisando uma string, qualquer que seja. A quantidade de entradas e saídas, bem como os pontos de decisão, devem, portanto, ser muito mínimos. O que facilita a gravação de testes.
  • Princípio de substituição de Liskov - você deve poder substituir stubs e zombarias sem alterar as propriedades desejáveis ​​(os resultados esperados) do seu código.
  • Princípio de segregação de interface - separar pontos de contato por interfaces facilita muito o uso de uma estrutura de simulação, como o Moq, para criar stubs e zombarias. Em vez de ter que confiar nas classes concretas, você está simplesmente confiando em algo que implementa a interface.
  • Princípio de injeção de dependência - é isso que permite injetar esses stubs e zombarias em seu código por meio de um construtor, uma propriedade ou um parâmetro no método que você deseja testar.

Eu também procuraria padrões diferentes, especialmente o padrão de fábrica. Digamos que você tenha uma classe concreta que implemente uma interface. Você criaria uma fábrica para instanciar a classe concreta, mas retornaria a interface.

public interface ISomeInterface
{
    int GetValue();
}  

public class SomeClass : ISomeInterface
{
    public int GetValue()
    {
         return 1;
    }
}

public interface ISomeOtherInterface
{
    bool IsSuccess();
}

public class SomeOtherClass : ISomeOtherInterface
{
     private ISomeInterface m_SomeInterface;

     public SomeOtherClass(ISomeInterface someInterface)
     {
          m_SomeInterface = someInterface;
     }

     public bool IsSuccess()
     {
          return m_SomeInterface.GetValue() == 1;
     }
}

public class SomeFactory
{
     public virtual ISomeInterface GetSomeInterface()
     {
          return new SomeClass();
     }

     public virtual ISomeOtherInterface GetSomeOtherInterface()
     {
          ISomeInterface someInterface = GetSomeInterface();

          return new SomeOtherClass(someInterface);
     }
}

Nos seus testes, você pode Moq ou alguma outra estrutura de simulação para substituir esse método virtual e retornar uma interface do seu design. Mas, no que diz respeito ao código de implementação, a fábrica não mudou. Você também pode ocultar muitos detalhes de sua implementação dessa maneira, seu código de implementação não se importa com a forma como a interface é criada, tudo o que importa é recuperar uma interface.

Se você quiser expandir um pouco isso, recomendo a leitura de The Art of Unit Testing . Ele fornece ótimos exemplos de como usar esses princípios e é uma leitura bastante rápida.


11
É chamado princípio de "inversão" de dependência, não princípio de "injeção".
Mathias Lykkegaard Lorenzen
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.