Teste de unidade é o processo de testar pequenos trechos de código (funções, métodos, propriedades, componentes) com o objetivo de garantir que cada trecho esteja funcionando corretamente.
Testes de unidade devem fazer parte da rotina de qualquer desenvolvedor, pois são escritos em tempo de desenvolvimento de projeto. À medida que novos códigos são escritos e publicados, novos testes são necessários para testar possíveis brechas e manter a cobertura de código em um nível adequado.
Os testes oferecem vários benefícios:
• Reduzem tempo gasto em testes funcionais
• Protegem contra regressão (defeitos que podem ser introduzidos com novos códigos)
• Exerce o papel de documentação executável (excelente fonte de aprendizado para novos membros do time)
• São rápidos (executam em milissegundos) e baratos (consomem pouco processamento)
• Atua como um meio para que desenvolvedores escrevam código de qualidade e sem acoplamento, para que possam realizar testes com mais facilidade
Ao alterar um código já existente ou incluir códigos que alterem o comportamento do sistema, os testes de unidade devem falhar ao serem executados novamente, atuando como um defensor dos comportamentos atuais do sistema. O desenvolvedor precisa ajustar o teste para que ele valide o novo comportamento inserido no sistema. Porém, em alguns casos, o desenvolvedor sabe que um determinado teste não deveria ter falhado, pois ele não alterou nada em determinada parte do sistema: esse é o grande objetivo dos testes, identificar defeitos em tempo de desenvolvimento, de forma rápida, barata e sem impactos.
Os testes devem ser uma fonte de confiança, para que, quando houver alterações no sistema, você possa tranquilamente executá-los e verificar se isso causou algum comportamento indesejado. Eles são indispensáveis e devem testar o sistema de forma correta e precisa.
É necessário garantir que os testes efetuem seu papel com competência, testando o comportamento do sistema e falhando quando necessário, caso contrário, defeitos podem ser inseridos no sistema acidentalmente e causar impactos em produção.
Se o objetivo dos testes é testar código, então como podemos testar os testes?
Antes de falarmos sobre isso, é importante não confundir a eficácia dos testes com a porcentagem de cobertura de testes do projeto. Enquanto o primeiro valida se os testes são confiáveis, o segundo é apenas uma métrica que representa a quantidade de código que está coberta por testes, e não deve ser entendida como um fator de sucesso para seu projeto ou que seu código e testes estejam bem escritos.
Testes Mutantes
É uma técnica que consiste em realizar mutações nos testes de unidade, inserindo defeitos propositalmente para alterar o comportamento do código.
As mutações nos testes podem ser feitas de várias maneiras e tipos, como por exemplo:
Mutações Aritméticas
Original | Mutação |
---|---|
a + b | a - b |
a * b | a / b |
a % b | a * b |
Mutações de Igualdade
Original | Mutação |
---|---|
a == b | a != b |
a < b | a <= b |
Mutações Lógicas
Original | Mutação |
---|---|
a && b | a || b |
a ?? b | a && b |
Mutações de Expressões
Original | Mutação |
---|---|
First() | Last() |
All() | Any() |
Skip() | Take() |
Min() | Max() |
Sum() | Count() |
Mutações de Expressões Regulares
Original | Mutação |
---|---|
[abc] | [^abc] |
\d | \D |
a* | a |
a{1,3} | a |
Mutações de Strings
Original | Mutação |
---|---|
foo | "" (string vazia) |
Todas as mutações disponíveis podem ser conferidas aqui.
Após um teste sofrer mutação, o resultado esperado é que ele falhe, pois a mutação altera o comportamento do sistema introduzindo defeitos de maneira proposital. Caso o teste falhe, significa que a mutação não sobreviveu e que o teste foi capaz de identificar os defeitos inseridos (pois ele falhou). Isso significa que o teste foi capaz de validar corretamente o comportamento esperado e pode ser considerado confiável.
Porém, caso o teste seja bem sucedido mesmo com as mutações, significa que esse teste deve ser melhorado o quanto antes, pois ele não foi capaz de validar defeitos inseridos no sistema, que é justamente o papel dele.
Realizando mutações com Stryker Mutator
O Stryker Mutator é uma ferramenta que está disponível para várias linguagens e realiza a mutação dos testes de unidade. Para utilizá-lo com C# é necessário instalar o Stryker.NET:
dotnet tool install -g dotnet-stryker
cd UnitTestsFolder # diretório de testes unitários da aplicação
dotnet new tool-manifest
dotnet tool install dotnet-stryker
Para executar o Stryker.NET criei uma calculadora bem simples:
public class Calculadora
{
public int Somar(int n1, int n2) => n1 + n2;
public int Subtrair(int n1, int n2) => n1 - n2;
public int Multiplicar(int n1, int n2) => n1 * n2;
public double Dividir(int n1, int n2) => n1 / n2;
}
Criei também testes de unidade para cada método:
[Fact]
public void Somar_Retorna_SomaEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
int soma = calculadora.Somar(0, 0);
//Assert
Assert.Equal(0, soma);
}
[Fact]
public void Subtrair_Retorna_SubtracaoEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
int subtracao = calculadora.Subtrair(0, 0);
//Assert
Assert.Equal(0, subtracao);
}
[Fact]
public void Multiplicacao_Retorna_MultiplicacaoEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
int multiplicacao = calculadora.Multiplicar(1, 1);
//Assert
Assert.Equal(1, multiplicacao);
}
[Fact]
public void Divisao_Retorna_DivisaoEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
double divisao = calculadora.Dividir(1, 1);
//Assert
Assert.Equal(1, divisao);
}
Ao executar os testes unitários, todos passaram com sucesso:
Para executar os testes mutantes utilizando o Stryker.NET:
dotnet stryker -o
O seguinte resultado é obtido:
Repare que 4 testes mutantes foram criados e todos sobreviveram após as mutações. Isso significa que os testes de unidade passaram, mesmo após alguns defeitos (mutações) terem sido introduzidos no sistema.
Devido a todos os testes mutantes terem sobrevivido, a pontuação final (mutation score) ficou em 0%.
Melhorando a qualidade dos testes
Para identificarmos as mutações que os testes sobreviveram, é necessário consultar o relatório que o Stryker Mutator fornece:
Como podemos ver na imagem acima, todos os testes sobreviveram à mutação aritmética que foi realizada.
Vamos analisar o teste de unidade da Soma, entender as validações realizadas e como é possível melhorar a qualidade do teste. Esse teste possui o seguinte comportamento:
1. Obtém uma instância de calculadora
2. Soma 0 + 0
3. Valida se o resultado do passo 2 é igual a zero
Ao realizarmos mutação aritmética neste teste, o sinal de soma será alterado para subtração, o que introduz um defeito no sistema e altera o comportamento do sistema para o seguinte:
1. Obtém uma instância de calculadora
2. Subtrai 0 - 0
3. Valida se o resultado do passo 2 é igual a zero
Ao executar esse teste após a mutação, ele ainda será bem sucedido, portanto, o teste mutante vai sobreviver. Isso significa que o teste deve ser melhorado ou refatorado, para validar corretamente alterações de comportamentos e regras de negócio.
Refatorando todos os testes anteriores:
[Fact]
public void Somar_Retorna_SomaEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
int soma = calculadora.Somar(5, 3);
//Assert
Assert.Equal(8, soma);
[Fact]
public void Subtrair_Retorna_SubtracaoEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
int subtracao = calculadora.Subtrair(10, 5);
//Assert
Assert.Equal(5, subtracao);
[Fact]
public void Multiplicacao_Retorna_MultiplicacaoEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
int multiplicacao = calculadora.Multiplicar(9, 2);
//Assert
Assert.Equal(18, multiplicacao);
[Fact]
public void Divisao_Retorna_DivisaoEntreDoisNumeros()
{
//Arrange
var calculadora = ObterCalculatora();
//Act
double divisao = calculadora.Dividir(6, 3);
//Assert
Assert.Equal(2, divisao);
}
Executando os testes mutantes novamente:
Podemos observar que, desta vez, todos os 4 testes mutantes não sobreviveram e tivemos o resultado esperado (100% de mutation score).
Desvantagens
Os testes mutantes não são rápidos para serem executados, pois várias mutações são realizadas em cada teste de unidade da aplicação, e conforme a quantidade de testes cresce, isso tende a piorar ainda mais.
Por conta disso, os testes mutantes são considerados caros e onerosos, pois consomem mais processamento, memória e tempo. Seu uso em pipelines pode ser inviável a médio e longo prazo.
Por ser um teste que leva tempo, não é recomendado ser feito sem a utilização de uma ferramenta de automação, como o Stryker Mutator demonstrado neste post.
Conclusão
Os testes de unidade exercem um papel fundamental para a qualidade de software, garantindo que a aplicação se comporte conforme o esperado e atenda as especificações de design e de negócio em tempo de desenvolvimento, oferecendo a oportunidade de corrigir defeitos antes de qualquer cliente ser impactado em ambiente produtivo.
Os testes são capazes de prevenir defeitos, aumentar a velocidade de codificação e fornecer mais confiabilidade no processo de desenvolvimento de software. Garantir que eles sejam codificados corretamente, isto é, validando com exatidão sua aplicação, é crucial para obter sucesso e diminuir o tempo decorrido para o lançamento de um novo produto ou novas funcionalidades.