Como medir a qualidade dos testes de unidade

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

OriginalMutação
a + ba - b
a * ba / b
a % ba * b

Mutações de Igualdade

OriginalMutação
a == ba != b
a < ba <= b

Mutações Lógicas

OriginalMutação
a && ba || b
a ?? ba && b

Mutações de Expressões

OriginalMutação
First()Last()
All()Any()
Skip()Take()
Min()Max()
Sum()Count()

Mutações de Expressões Regulares

OriginalMutação
[abc][^abc]
\d\D
a*a
a{1,3}a

Mutações de Strings

OriginalMutaçã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:

unitTestResult

Para executar os testes mutantes utilizando o Stryker.NET:

dotnet stryker -o

O seguinte resultado é obtido:

strykerInitialResult

strykerInitialReport

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:

strykerMutationSurvived

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:

strykerFinalResult

strykerFinalReport

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.