Na programação e no TDD, em particular, existem bons princípios aos quais é útil aderir: SECO e teste através de métodos públicos. Eles se provaram repetidamente na prática, mas em projetos com um grande código legado, eles podem ter um "lado sombrio". Por exemplo, você pode escrever um código guiado por esses princípios e depois se desmontar dos testes, cobrindo mais de 20 abstrações com uma configuração incomparavelmente maior que a lógica testada. Esse "lado negro" assusta as pessoas e inibe o uso de TDD em projetos. Neste artigo, discuto por que o teste através de métodos públicos é ruim e como reduzir os problemas que surgem devido a esse princípio.
Isenção de responsabilidadeImediatamente, quero dissipar a possível impressão. Alguns podem nem sentir as desvantagens que serão discutidas, devido, por exemplo, ao tamanho de seus projetos. Além disso, essas deficiências, na minha opinião, fazem parte da dívida técnica e têm as mesmas características: o problema aumentará se não for prestado atenção. Portanto, é necessário decidir de acordo com a situação.
A ideia subjacente ao princípio parece boa: você precisa testar o comportamento, não a implementação. Isso significa que você só precisa testar a interface da classe. Na prática, esse nem sempre é o caso. Para apresentar a essência do problema, imagine que você tenha um método que calcula o custo dos trabalhadores envolvidos no trabalho por turnos. Essa é uma tarefa não trivial quando se trata de mudar de trabalho, pois eles têm dicas, bônus, fins de semana, feriados, regras corporativas etc. etc. Esse método realiza muitas operações internamente e usa outros serviços que fornecem informações sobre feriados, dicas etc. ao escrever um teste de unidade para isso, é necessário criar uma configuração para todos os serviços utilizados, se o código testado estiver em algum lugar no final do método. Ao mesmo tempo, o próprio código testado pode usar apenas parcialmente ou não usar serviços configuráveis. E já existem alguns testes de unidade escritos dessa maneira.
Menos 1: excesso de configuração do teste de unidade
Agora você deseja adicionar uma reação a um novo recurso que possui lógica não trivial e também é usado em algum lugar no final do método. A natureza do sinalizador é tal que faz parte da lógica do serviço e, ao mesmo tempo, não faz parte da interface do serviço. No caso acima, esse código é relevante apenas para esse método público e geralmente pode ser inscrito no método antigo.
Se o projeto adotou a regra para testar tudo apenas por métodos públicos, o desenvolvedor provavelmente pode apenas copiar alguns testes de unidade existentes e ajustá-los um pouco. No novo teste, ainda haverá uma configuração de todos os serviços para executar o método. Por um lado, cumprimos o princípio, mas, por outro lado, fizemos um teste de unidade com excesso de configuração. No futuro, se algo quebrar, ou exigir uma alteração na configuração, você terá que fazer o trabalho do macaco para ajustar os testes. É tedioso, longo e não traz alegria nem aparente benefício ao cliente. Parece que estamos seguindo o princípio correto, mas estamos na mesma situação em que queríamos nos afastar, recusando-se a testar métodos privados.
Menos 2: cobertura incompleta
Além disso, um fator humano como a preguiça pode intervir. Por exemplo, um método privado com lógica de bandeira não trivial pode parecer neste exemplo.
private bool HasShifts(DateTime date, int tolerance, bool clockIn, Shift[] shifts, int[] locationIds) { bool isInLimit(DateTime date1, DateTime date2, int limit) => Math.Abs(date2.Subtract(date1).TotalMinutes) <= limit; var shiftsOfLocations = shifts.Where(x => locationIds.Contains(x.LocationId)); return clockIn ? shiftsOfLocations.Any(x => isInLimit(date, x.StartDate, tolerance)) : shiftsOfLocations.Any(x => isInLimit(date, x.EndDate, tolerance)); }
Este método requer 10 verificações para cobrir todos os casos, 8 dos quais são significativos.
Decodificando 8 casos importantes- shiftsOfLocations - 2 valores - independentemente de ser ou não
- clockIn - 2 valores - verdadeiro ou falso
- tolerância - 2 significados diferentes
Total: 2 x 2 x 2 = 8
Ao escrever testes de unidade para testar essa lógica, o desenvolvedor precisará escrever pelo menos 8 grandes testes de unidade. Me deparei com casos em que a configuração do teste de unidade ocupava mais de 50 linhas de código, com 4 linhas de uma chamada direta. I.e. apenas cerca de 10% do código carrega uma carga útil. Nesse caso, é grande a tentação de reduzir a quantidade de trabalho escrevendo menos testes de unidade. Como resultado, de 8, por exemplo, apenas dois testes de unidade permanecem, para cada valor clockIn. Essa situação leva ao fato de que, novamente, é entediante e demorado escrever todos os testes necessários, criando a configuração (Ctrl + C, V funciona, onde seria sem ela) ou o método permanece apenas parcialmente coberto. Cada opção tem suas conseqüências desagradáveis.
Possíveis soluções
Além do princípio "comportamento de teste", ainda existe OCP (princípio aberto / fechado). Aplicando-o corretamente, você pode esquecer o que são "testes frágeis", testando o comportamento interno do módulo. Se você precisar de um novo comportamento do módulo, escreverá novos testes de unidade para a nova classe sucessora na qual o comportamento necessário será alterado. Então você não precisará gastar tempo revisando e atualizando os testes existentes. Nesse caso, esse método pode ser declarado como interno ou interno protegido e testado adicionando InternalsVisibleTo ao assembly. Nesse caso, sua interface IClass não sofrerá e os testes serão os mais lacônicos, não sujeitos a alterações frequentes.
Outra alternativa seria declarar uma classe auxiliar adicional na qual nosso método pode ser extraído, declarando-o como público. Então, o princípio será observado e o teste será conciso. Na minha opinião, essa abordagem nem sempre compensa. Por exemplo, alguns podem decidir puxar até um método para uma classe, o que leva à criação de várias classes com um método. O outro extremo pode ser despejar esses métodos em uma classe auxiliar, que se transforma em uma classe auxiliar de DEUS. Mas essa opção com um auxiliar pode ser a única se o conjunto de trabalho estiver assinado com um nome forte e você não puder assinar o conjunto de teste por algum motivo. InternalsVisibleTo funcionará quando os dois assemblies forem assinados ou não de uma vez.
Sumário
E, no final, devido a uma combinação de tais problemas, a idéia de TDD e testes de unidade sofre, porque ninguém deseja escrever testes volumétricos e apoiá-los. Ficarei feliz em ver exemplos de como a adesão estrita a esse princípio levou a problemas e a uma motivação reduzida para escrever testes da equipe de desenvolvimento.