Teste de unidade .NET: gaste adiantado para economizar mais tarde
Publicados: 2022-03-11Muitas vezes, há muita confusão e dúvida em relação ao teste de unidade ao discuti-lo com as partes interessadas e os clientes. O teste de unidade às vezes soa como o fio dental para uma criança: “Eu já escovo meus dentes, por que preciso fazer isso?”
Sugerir testes de unidade geralmente soa como uma despesa desnecessária para pessoas que consideram seus métodos de teste e testes de aceitação do usuário suficientemente fortes.
Mas os Testes Unitários são uma ferramenta muito poderosa e são mais simples do que você imagina. Neste artigo, veremos os testes de unidade e quais ferramentas estão disponíveis no DotNet, como Microsoft.VisualStudio.TestTools e Moq .
Tentaremos construir uma biblioteca de classes simples que calculará o enésimo termo na sequência de Fibonacci. Para fazer isso, queremos criar uma classe para calcular sequências de Fibonacci que dependa de uma classe matemática personalizada que soma os números. Em seguida, podemos usar o .NET Testing Framework para garantir que nosso programa seja executado conforme o esperado.
O que é Teste Unitário?
O teste de unidade divide o programa no menor pedaço de código, geralmente no nível da função, e garante que a função retorne o valor esperado. Ao usar uma estrutura de teste de unidade, os testes de unidade se tornam uma entidade separada que pode executar testes automatizados no programa à medida que ele está sendo construído.
[TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }
Um teste de unidade simples usando o teste de metodologia Organizar, Agir, Asserir que nossa biblioteca de matemática pode adicionar corretamente 2 + 2.
Uma vez que os testes de unidade são configurados, se uma mudança for feita no código, para levar em conta uma condição adicional que não era conhecida quando o programa foi desenvolvido pela primeira vez, por exemplo, os testes de unidade mostrarão se todos os casos correspondem aos valores esperados saída pela função.
Teste de unidade não é teste de integração. Não é um teste de ponta a ponta. Embora ambas sejam metodologias poderosas, elas devem funcionar em conjunto com o teste de unidade – não como uma substituição.
Os benefícios e finalidade do teste de unidade
O benefício do teste de unidade mais difícil de entender, mas o mais importante, é a capacidade de testar novamente o código alterado em tempo real. A razão pela qual pode ser tão difícil de entender é porque muitos desenvolvedores pensam consigo mesmos: “Nunca mais tocarei nessa função” ou “Vou testá-la novamente quando terminar”. E as partes interessadas pensam em termos de: “Se essa peça já está escrita, por que preciso testá-la novamente?”
Como alguém que esteve em ambos os lados do espectro de desenvolvimento, eu disse as duas coisas. O desenvolvedor dentro de mim sabe por que temos que testá-lo novamente.
As mudanças que fazemos no dia-a-dia podem ter enormes impactos. Por exemplo:
- O seu switch considera adequadamente um novo valor que você colocou?
- Você sabe quantas vezes você usou esse interruptor?
- Você considerou corretamente as comparações de strings que não diferenciam maiúsculas de minúsculas?
- Você está verificando os nulos adequadamente?
- Uma exceção de lançamento é tratada como você esperava?
O teste de unidade pega essas perguntas e as transforma em código e em um processo para garantir que essas perguntas sejam sempre respondidas. Testes de unidade podem ser executados antes de uma compilação para garantir que você não introduziu novos bugs. Como os testes de unidade são projetados para serem atômicos, eles são executados muito rapidamente, geralmente menos de 10 milissegundos por teste. Mesmo em um aplicativo muito grande, um conjunto de testes completo pode ser executado em menos de uma hora. Seu processo UAT pode corresponder a isso?
Fibonacci_GetNthTerm_Input2_AssertResult1
, que é a primeira execução e inclui o tempo de configuração, todos os testes de unidade são executados em 5 ms. Minha convenção de nomenclatura aqui está configurada para pesquisar facilmente uma classe ou método dentro de uma classe que eu quero testar
No entanto, como desenvolvedor, talvez isso pareça mais trabalho para você. Sim, você fica tranquilo que o código que está lançando é bom. Mas o teste de unidade também oferece a oportunidade de ver onde seu design é fraco. Você está escrevendo os mesmos testes de unidade para dois pedaços de código? Eles deveriam estar em um pedaço de código em vez disso?
Fazer com que seu código seja testável por unidade é uma maneira de melhorar seu design. E para a maioria dos desenvolvedores que nunca fizeram testes unitários ou não levam muito tempo para considerar o design antes de codificar, você pode perceber o quanto seu design melhora ao prepará-lo para testes unitários.
Sua unidade de código é testável?
Além do DRY, também temos outras considerações.
Seus métodos ou funções estão tentando fazer demais?
Se você precisar escrever testes de unidade excessivamente complexos que estão sendo executados por mais tempo do que o esperado, seu método pode ser muito complicado e mais adequado como vários métodos.
Você está aproveitando adequadamente a injeção de dependência?
Se o seu método em teste requer outra classe ou função, chamamos isso de dependência. No teste de unidade, não nos importamos com o que a dependência está fazendo nos bastidores; para efeitos do método em teste, é uma caixa preta. A dependência possui seu próprio conjunto de testes de unidade que determinarão se seu comportamento está funcionando corretamente.
Como testador, você deseja simular essa dependência e informar quais valores retornar em instâncias específicas. Isso lhe dará maior controle sobre seus casos de teste. Para fazer isso, você precisará injetar uma versão fictícia (ou, como veremos mais adiante, simulada) dessa dependência.
Seus componentes interagem entre si como você espera?
Depois de trabalhar suas dependências e sua injeção de dependência, você pode descobrir que introduziu dependências cíclicas em seu código. Se a Classe A depende da Classe B, que por sua vez depende da Classe A, você deve reconsiderar seu projeto.
A beleza da injeção de dependência
Vamos considerar nosso exemplo de Fibonacci. Seu chefe informa que eles têm uma nova classe que é mais eficiente e precisa do que o operador add atual disponível em C#.
Embora esse exemplo em particular não seja muito provável no mundo real, vemos exemplos análogos em outros componentes, como autenticação, mapeamento de objetos e praticamente qualquer processo algorítmico. Para os propósitos deste artigo, vamos apenas fingir que a nova função add do seu cliente é a mais recente e melhor desde que os computadores foram inventados.
Como tal, seu chefe lhe entrega uma biblioteca de caixa preta com uma única classe Math
, e nessa classe, uma única função Add
. Seu trabalho de implementar uma calculadora de Fibonacci provavelmente será algo assim:
public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }
Isso não é horrível. Você instancia uma nova classe Math
e a usa para adicionar os dois termos anteriores para obter o próximo. Você executa esse método por meio de sua bateria normal de testes, calculando até 100 termos, calculando o 1000º termo, o 10.000º termo e assim por diante até se sentir satisfeito de que sua metodologia funciona bem. Então, em algum momento no futuro, um usuário reclama que o 501º termo não está funcionando conforme o esperado. Você passa a noite examinando seu código e tentando descobrir por que esse caso de canto não está funcionando. Você começa a suspeitar que a última e melhor aula de Math
não é tão boa quanto seu chefe pensa. Mas é uma caixa preta e você não pode provar isso – você chega a um impasse internamente.
O problema aqui é que a dependência Math
não é injetada em sua calculadora Fibonacci. Portanto, em seus testes, você sempre confia nos resultados existentes, não testados e desconhecidos do Math
para testar Fibonacci. Se houver um problema com Math
, Fibonacci sempre estará errado (sem codificar um caso especial para o 501º termo).
A ideia para corrigir esse problema é injetar a classe Math
em sua calculadora Fibonacci. Mas ainda melhor, é criar uma interface para a classe Math
que defina os métodos públicos (no nosso caso, Add
) e implemente a interface em nossa classe Math
.
public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }
Em vez de injetar a classe Math
em Fibonacci, podemos injetar a interface IMath
em Fibonacci. O benefício aqui é que podemos definir nossa própria classe OurMath
que sabemos ser precisa e testar nossa calculadora contra isso. Melhor ainda, usando Moq podemos simplesmente definir o que o Math.Add
retorna. Podemos definir um número de somas ou podemos simplesmente dizer ao Math.Add
para retornar x + y.
private IMath _math; public Fibonacci(IMath math) { _math = math; }
Injete a interface IMath na classe Fibonacci

//setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);
Usando Moq para definir o que Math.Add
retorna.
Agora temos um método testado e comprovado (bem, se esse operador + estiver errado em C#, temos problemas maiores) para adicionar dois números. Usando nosso novo Mocked IMath
, podemos codificar um teste de unidade para nosso 501º termo e ver se enganamos nossa implementação ou se a classe Math
personalizada precisa de um pouco mais de trabalho.
Não deixe um método tentar fazer demais
Este exemplo também aponta para a ideia de um método fazendo muito. Claro, a adição é uma operação bastante simples, sem muita necessidade de abstrair sua funcionalidade do nosso método GetNthTerm
. Mas e se a operação fosse um pouco mais complicada? Em vez de adição, talvez fosse validação de modelo, chamando uma fábrica para obter um objeto para operar ou coletando dados adicionais necessários de um repositório.
A maioria dos desenvolvedores tentará manter a ideia de que um método tem um propósito. Nos testes de unidade, tentamos manter o princípio de que os testes de unidade devem ser aplicados a métodos atômicos e, ao introduzir muitas operações em um método, o tornamos não testável. Muitas vezes podemos criar um problema em que temos que escrever tantos testes para testar adequadamente nossa função.
Cada parâmetro que adicionamos a um método aumenta o número de testes que temos que escrever exponencialmente de acordo com a complexidade do parâmetro. Se você adicionar um booleano à sua lógica, precisará dobrar o número de testes a serem gravados, pois agora você precisa verificar os casos verdadeiro e falso junto com seus testes atuais. No caso de validação de modelo, a complexidade de nossos testes unitários pode aumentar muito rapidamente.
Somos todos culpados de adicionar um pouco mais a um método. Mas esses métodos maiores e mais complexos criam a necessidade de muitos testes de unidade. E rapidamente se torna aparente quando você escreve os testes de unidade que o método está tentando fazer muito. Se você sentir que está tentando testar muitos resultados possíveis de seus parâmetros de entrada, considere o fato de que seu método precisa ser dividido em uma série de outros menores.
Não se repita
Um dos nossos inquilinos favoritos de programação. Este deve ser bastante direto. Se você estiver escrevendo os mesmos testes mais de uma vez, você introduziu o código mais de uma vez. Pode ser benéfico refatorar esse trabalho em uma classe comum que seja acessível a ambas as instâncias em que você está tentando usá-lo.
Quais ferramentas de teste de unidade estão disponíveis?
DotNet nos oferece uma plataforma de teste de unidade muito poderosa pronta para uso. Usando isso, você pode implementar o que é conhecido como a metodologia Organizar, Agir, Asserir. Você organiza suas considerações iniciais, age de acordo com essas condições com seu método em teste e afirma que algo aconteceu. Você pode afirmar qualquer coisa, tornando esta ferramenta ainda mais poderosa. Você pode afirmar que um método foi chamado um número específico de vezes, que o método retornou um valor específico, que um tipo específico de exceção foi lançado ou qualquer outra coisa que você possa imaginar. Para aqueles que procuram uma estrutura mais avançada, o NUnit e sua contraparte Java JUnit são opções viáveis.
[TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }
Testando que nosso método Fibonacci lida com números negativos lançando uma exceção. Os testes de unidade podem verificar se a exceção foi lançada.
Para lidar com a injeção de dependência, o Ninject e o Unity existem na plataforma DotNet. Há muito pouca diferença entre os dois, e torna-se uma questão de se você deseja gerenciar configurações com Sintaxe Fluente ou Configuração XML.
Para simular as dependências, recomendo o Moq. O Moq pode ser um desafio, mas a essência é que você cria uma versão simulada de suas dependências. Em seguida, você informa à dependência o que retornar sob condições específicas. Por exemplo, se você tivesse um método chamado Square(int x)
que elevasse o número inteiro ao quadrado, você poderia dizer quando x = 2, retornar 4. Você também poderia dizer a ele para retornar x^2 para qualquer número inteiro. Ou você pode dizer para ele retornar 5 quando x = 2. Por que você executaria o último caso? Caso o método sob a função do teste seja validar a resposta da dependência, talvez você queira forçar o retorno de respostas inválidas para garantir que você esteja detectando o bug corretamente.
[TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }
Usando o Moq para informar à interface IMath
como lidar com Add
em teste. Você pode definir casos explícitos com It.Is
ou um intervalo com It.IsInRange
.
Estruturas de teste de unidade para DotNet
Estrutura de teste de unidade da Microsoft
O Microsoft Unit Testing Framework é a solução de teste de unidade pronta para uso da Microsoft e incluída no Visual Studio. Porque vem com o VS, integra-se bem com ele. Quando você inicia um projeto, o Visual Studio perguntará se você deseja criar uma Biblioteca de Teste de Unidade ao lado de seu aplicativo.
O Microsoft Unit Testing Framework também vem com várias ferramentas para ajudá-lo a analisar melhor seus procedimentos de teste. Além disso, como é de propriedade e escrito pela Microsoft, há algum sentimento de estabilidade em sua existência daqui para frente.
Mas ao trabalhar com ferramentas da Microsoft, você obtém o que elas oferecem. O Microsoft Unit Testing Framework pode ser complicado de integrar.
NUnit
A maior vantagem para mim no uso do NUnit são os testes parametrizados. Em nosso exemplo de Fibonacci acima, podemos inserir vários casos de teste e garantir que esses resultados sejam verdadeiros. E no caso do nosso 501º problema, sempre podemos adicionar um novo conjunto de parâmetros para garantir que o teste seja sempre executado sem a necessidade de um novo método de teste.
A principal desvantagem do NUnit é integrá-lo ao Visual Studio. Falta os sinos e assobios que vêm com a versão da Microsoft e significa que você precisará baixar seu próprio conjunto de ferramentas.
xUnit.Net
xUnit é muito popular em C# porque se integra muito bem com o ecossistema .NET existente. O Nuget tem muitas extensões do xUnit disponíveis. Ele também se integra muito bem ao Team Foundation Server, embora eu não tenha certeza de quantos desenvolvedores .NET ainda usam o TFS em várias implementações do Git.
No lado negativo, muitos usuários reclamam que a documentação do xUnit é um pouco deficiente. Para novos usuários para testes de unidade, isso pode causar uma enorme dor de cabeça. Além disso, a extensibilidade e adaptabilidade do xUnit também tornam a curva de aprendizado um pouco mais íngreme que o NUnit ou o Unit Testing Framework da Microsoft.
Design/Desenvolvimento Orientado a Testes
Design/desenvolvimento orientado a testes (TDD) é um tópico um pouco mais avançado que merece um post próprio. No entanto, eu queria fornecer uma introdução.
A ideia é começar com seus testes de unidade e dizer a seus testes de unidade o que está correto. Então, você pode escrever seu código em torno desses testes. Em teoria, o conceito parece simples, mas, na prática, é muito difícil treinar seu cérebro para pensar para trás sobre a aplicação. Mas a abordagem tem o benefício interno de não ser necessário escrever seus testes de unidade após o fato. Isso leva a menos refatoração, reescrita e confusão de classes.
TDD tem sido uma palavra da moda nos últimos anos, mas a adoção tem sido lenta. Sua natureza conceitual é confusa para as partes interessadas, o que dificulta sua aprovação. Mas como desenvolvedor, encorajo você a escrever até mesmo um pequeno aplicativo usando a abordagem TDD para se acostumar com o processo.
Por que você não pode ter muitos testes de unidade
O teste de unidade é uma das ferramentas de teste mais poderosas que os desenvolvedores têm à sua disposição. Não é suficiente para um teste completo de seu aplicativo, mas seus benefícios em testes de regressão, design de código e documentação de propósito são incomparáveis.
Não existe isso de escrever muitos testes de unidade. Cada caso extremo pode propor grandes problemas no futuro em seu software. Memorizar bugs encontrados como testes de unidade pode garantir que esses bugs não encontrem maneiras de voltar ao seu software durante as alterações de código posteriores. Embora você possa adicionar de 10 a 20% ao orçamento inicial do seu projeto, você pode economizar muito mais do que isso em treinamento, correções de bugs e documentação.
Você pode encontrar o repositório Bitbucket usado neste artigo aqui.