Testes de mutação com Stryker.NET

Os testes de mutação avaliam a qualidade da sua suíte de testes ao inserir bugs temporários no código e verificar se algum teste percebe a mudança. No ecossistema .NET, a ferramenta mais conhecida para isso é o Stryker.NET, que gera os mutantes, executa os testes e entrega um relatório detalhado.

Neste artigo, você vai entender o conceito, instalar a ferramenta e aplicar tudo em um exemplo prático com xUnit. Além disso, vai aprender a interpretar o relatório e a corrigir tanto o código quanto os testes.

O que são testes de mutação

Cobertura de código mostra quais linhas os testes executam, mas não mostra se eles realmente verificam alguma coisa. Por isso, uma suíte pode ter cobertura alta e, mesmo assim, deixar passar defeitos óbvios.

Os testes de mutação atacam exatamente esse ponto. A ideia é modificar uma pequena parte do código, criando um bug proposital, e rodar a suíte inteira contra essa versão alterada.

Cada versão alterada recebe o nome de mutante. Se a suíte for boa, pelo menos um teste falha quando o mutante está ativo.

Em outras palavras, um teste só tem valor quando consegue falhar diante de uma implementação incorreta. Quanto maior a porcentagem de mutantes detectados, mais eficaz é a sua suíte.

Quando um mutante morre ou sobrevive

A lógica pode parecer invertida à primeira vista, então vale atenção. Quando algum teste falha com o mutante ativo, o mutante morre, e isso é bom.

Por outro lado, quando todos os testes passam com o mutante ativo, ele sobrevive. Nesse caso, a suíte não percebeu o bug, e você encontrou um teste faltando ou uma asserção fraca.

Estados dos mutantes

A documentação do Stryker sobre estados e métricas define os seguintes estados:

  • Killed: pelo menos um teste falhou enquanto o mutante estava ativo.
  • Survived: todos os testes passaram enquanto o mutante estava ativo.
  • No coverage: nenhum teste cobre o mutante, então ele sobreviveu.
  • Timeout: a execução com o mutante ativo estourou o tempo, por exemplo, por um laço infinito.
  • Runtime error: a execução resultou em erro, e não em teste com falha.
  • Compile error: o mutante gerou um erro de compilação, algo comum em linguagens compiladas.
  • Ignored: o mutante não foi testado, por decisão do usuário ou por outro motivo.

Os estados Survived e No coverage merecem mais atenção. Afinal, eles apontam diretamente para lacunas na suíte.

Como calcular o mutation score

O indicador principal dos testes de mutação é o mutation score. Segundo a documentação, ele é a razão entre mutantes detectados e mutantes válidos, multiplicada por 100.

Os detectados somam Killed e Timeout. Já os não detectados somam Survived e No coverage, enquanto os erros de compilação e de execução ficam de fora como inválidos.

Existe ainda uma variação que considera apenas o código coberto. Nesse caso, a conta divide os detectados pela soma de detectados e sobreviventes.

Testes de mutação com Stryker.NET

O Stryker.NET é a versão para .NET do projeto Stryker Mutator, que também tem versões para JavaScript e Scala. Ele permite testar seus testes de unidade ao inserir bugs temporários no código de produção.

A versão original deste artigo indicava que a ferramenta funcionava a partir do .NET 5. Hoje, a página de primeiros passos do Stryker.NET pede o runtime do .NET 8 ou superior para rodar a ferramenta, mas o seu projeto não precisa mirar o .NET 8.

O próprio Microsoft Learn tem uma página dedicada a mutation testing no .NET que usa o Stryker.NET como exemplo. Portanto, a ferramenta é uma escolha consolidada na plataforma.

Tipos de mutação mais comuns

O Stryker.NET aplica várias categorias de mutação ao código. A documentação da Microsoft destaca quatro delas:

  • Equivalência: troca um operador por um parecido, como x < y por x <= y.
  • Aritmética: troca um operador aritmético, como x + y por x - y.
  • String: troca o conteúdo de uma string, como "text" por "".
  • Lógica: troca um operador lógico, como x && y por x || y.

Repare que a troca de < por <= é justamente o tipo de erro que escapa em revisões de código. É exatamente esse defeito que o exemplo a seguir explora.

Como o Stryker.NET gera os mutantes

Compilar o projeto uma vez para cada mutante seria lento demais. Por isso, o Stryker.NET usa uma técnica chamada mutant schemata, que embute todas as mutações em um único binário.

Em tempo de execução, uma variável de ambiente decide qual mutante fica ativo. Na prática, o código gerado contém condições parecidas com esta:

// Cada mutação fica protegida por uma verificação da mutação ativa
if (Environment.GetEnvironmentVariable("ActiveMutation") == "1")
{
    // versão mutada do código
}
else
{
    // versão original do código
}

Quando uma mutação quebra a compilação, a própria ferramenta remove os trechos problemáticos e compila de novo. Segundo a documentação, a compilação incremental do Roslyn deixa essas tentativas rápidas, e os mutantes removidos recebem o estado de erro de compilação.

Existe uma limitação conhecida: o Stryker.NET não consegue mutar valores constantes em contextos que exigem constantes de compilação. Assim, alguns trechos simplesmente não geram mutantes.

Preparando o projeto de exemplo

Neste exemplo, usamos o xUnit para os testes e a biblioteca FluentAssertions para as asserções com Should(). Se preferir, use este template no GitHub, que já traz as classes e os testes implementados.

Você também pode clonar o projeto diretamente pelo endereço https://github.com/Eliandro-Freitas/TemplateMutationTests.git. Em seguida, abra a solução na sua IDE de preferência.

A regra de negócio

O template traz uma classe Toy com o método IsRecommendedAge. A regra diz que o brinquedo é recomendado para crianças entre 2 e 10 anos:

public class Toy
{
    public bool IsRecommendedAge(int age)
    {
        if (age >= 2 && age < 10)
            return true;

        return false;
    }
}

Perceba o detalhe: a condição usa age < 10, então ela aceita apenas até 9 anos. É apenas um sinal de igual esquecido, mas ele pode virar um bug em produção.

Os testes iniciais

Agora veja os dois testes que acompanham a classe:

public class ToyTests
{
    [Fact]
    public void ShouldReturnTrueWhenAgeIsRecommended()
    {
        var result = new Toy().IsRecommendedAge(4);
        result.Should().BeTrue();
    }

    [Fact]
    public void ShouldReturnFalseWhenAgeIsNotRecommended()
    {
        var result = new Toy().IsRecommendedAge(15);
        result.Should().BeFalse();
    }
}

Esses dois testes exercitam os dois caminhos do if, e ambos passam. No entanto, a demanda não está atendida, porque uma criança de 10 anos recebe false.

Ou seja, a cobertura de linhas chega a 100% e o bug continua lá. É aqui que os testes de mutação mostram seu valor.

Por que a cobertura de código não basta

Uma ferramenta de cobertura marcaria as duas linhas do método IsRecommendedAge como cobertas. Afinal, um teste entra no if e o outro cai no return false.

Mesmo assim, nenhum dos testes verifica as idades 2 e 10, que são justamente os limites da regra. A cobertura responde se a linha rodou, mas não responde se alguém conferiu o resultado certo.

Os testes de mutação respondem a segunda pergunta. Eles mudam o operador da fronteira e observam se algum teste percebe a diferença.

Instalando a ferramenta de testes de mutação

Com o template aberto, abra o Package Manager Console do Visual Studio, o Prompt de Comando ou o PowerShell. Depois, instale a ferramenta global com o comando abaixo:

dotnet tool install -g dotnet-stryker

Se preferir manter a versão fixa por repositório, a documentação também mostra a instalação local. Nesse caso, crie um manifesto de ferramentas e instale o Stryker dentro dele:

dotnet new tool-manifest
dotnet tool install dotnet-stryker

Assim, você versiona o arquivo dotnet-tools.json junto com o código. Com isso, qualquer pessoa da equipe restaura a ferramenta com dotnet tool restore.

Executando os testes de mutação

Navegue até a pasta onde está o arquivo .csproj do projeto de testes. A documentação reforça que o diretório de trabalho precisa ser o do projeto de testes, porque o Stryker descobre sozinho o projeto de código.

Em seguida, execute o comando:

dotnet stryker

O Stryker compila o projeto, gera os mutantes e roda a suíte contra cada um deles. Ao final, ele mostra um resumo no terminal, como na imagem abaixo.

Terminal exibindo a execução do comando dotnet stryker e o resumo dos testes de mutação
Resumo da execução do Stryker.NET exibido no terminal.

Se algo der errado, rode dotnet stryker --verbosity trace --log-to-file. Dessa forma, você obtém um log detalhado para investigar o problema.

Abrindo o relatório HTML

Além do resumo, o Stryker salva um relatório HTML detalhado na pasta StrykerOutput. Copie o caminho que aparece no terminal e abra o arquivo no navegador.

Caminho do relatório HTML gerado pelo Stryker.NET destacado na saída do terminal
O terminal informa onde está o relatório gerado.

Lendo o relatório dos testes de mutação

No navegador, o relatório mostra quantos mutantes o Stryker criou, quantos morreram e quantos sobreviveram. Também aparece o score geral da execução.

Relatório HTML dos testes de mutação do Stryker.NET com o mutation score e a contagem de mutantes mortos e sobreviventes
Visão geral do relatório com o score e a contagem por estado.

O número mais importante aqui é o de mutantes sobreviventes. Afinal, cada sobrevivente indica um teste que está faltando ou uma asserção fraca.

Mutantes mortos

Ao selecionar o filtro Killed, você vê todas as alterações que os testes detectaram. Cada marcador verde representa uma mutação que fez algum teste falhar.

Filtro Killed do relatório mostrando as mutações detectadas pelos testes no método da classe Toy
Mutações que a suíte detectou aparecem marcadas em verde.

Mutantes sobreviventes

Já o filtro Survived mostra as alterações que passaram despercebidas pelos testes. No nosso caso, a troca de age < 10 por age <= 10 não derruba nenhum teste, porque nenhum deles usa a idade 10.

Algo parecido acontece com age >= 2, já que nenhum teste usa a idade 2. Portanto, os testes de mutação revelam exatamente as fronteiras que a suíte ignora.

Filtro Survived do relatório listando as mutações que nenhum teste detectou
Mutantes sobreviventes indicam fronteiras sem teste.

Mesmo assim, fique atento. Nem toda mutação sobrevivente pede uma mudança, e cabe a você analisar o que faz sentido para a regra de negócio.

Corrigindo o código e os testes

Com esses dados, ajuste o método para aceitar também a idade de 10 anos:

public bool IsRecommendedAge(int age)
{
    if (age >= 2 && age <= 10)
        return true;

    return false;
}

Em seguida, transforme os testes em Theory com vários valores de entrada. Assim, eles ficam mais dinâmicos e cobrem as fronteiras da regra:

[Theory]
[InlineData(2)]
[InlineData(4)]
[InlineData(10)]
public void ShouldReturnTrueWhenAgeIsRecommended(int age)
{
    var result = new Toy().IsRecommendedAge(age);
    result.Should().BeTrue();
}

[Theory]
[InlineData(1)]
[InlineData(11)]
[InlineData(15)]
public void ShouldReturnFalseWhenAgeIsNotRecommended(int age)
{
    var result = new Toy().IsRecommendedAge(age);
    result.Should().BeFalse();
}

Repare que os valores 1, 2, 10 e 11 ficam exatamente em volta dos limites. Por isso, qualquer troca entre < e <= agora quebra pelo menos um teste.

Rode a suíte novamente e confirme que todos os testes passam.

Gerenciador de testes mostrando todos os casos da classe ToyTests aprovados
Os novos casos de teste passam com a regra corrigida.

Rodando os testes de mutação de novo

Agora execute dotnet stryker mais uma vez na pasta do projeto de testes. Depois, abra o novo relatório no navegador.

Novo relatório dos testes de mutação sem nenhum mutante sobrevivente
Depois dos ajustes, nenhum mutante sobrevive.

Dessa vez, nenhum mutante sobreviveu. Com isso, os testes protegem a regra de verdade, e o método está de acordo com o negócio.

Configurando os testes de mutação no projeto

O Stryker funciona sem configuração na maioria dos projetos. Ainda assim, você pode criar um arquivo stryker-config.json para guardar opções permanentes.

A documentação da Microsoft mostra um exemplo com três opções úteis:

{
  "stryker-config": {
    "ignore-mutations": [
      "string",
      "logical"
    ],
    "ignore-methods": [
      "*Logs"
    ],
    "mutate": [
      "!**/Migrations/*",
      "!**/*.Designer.cs"
    ]
  }
}

A opção ignore-mutations exclui tipos de mutação que geram ruído, enquanto ignore-methods pula métodos pela assinatura. Ambos aparecem no relatório como Ignored.

Já a opção mutate filtra arquivos e pastas. No exemplo, ela ignora migrações e arquivos .Designer.cs, que normalmente vêm de geradores.

Testes de mutação no pipeline

Você também pode rodar o Stryker.NET em pipelines do Azure Pipelines ou do GitHub Actions. Dessa forma, a equipe define limites mínimos de qualidade e acompanha a evolução da suíte a cada mudança.

Como os testes de mutação executam a suíte muitas vezes, eles costumam demorar mais que um build comum. Por isso, vale começar pelos projetos mais críticos.

Boas práticas

A Microsoft recomenda não perseguir 100% de mutation score. Em vez disso, foque nas áreas de alto risco ou críticas para o negócio, onde um bug escondido custaria mais caro.

Além disso, trate cada sobrevivente como uma pergunta, e não como uma ordem. Às vezes, a mutação é equivalente ao código original e nenhum teste conseguiria matá-la.

Comece pelos mutantes no estado No coverage, porque eles mostram código que nenhum teste executa. Depois, passe para os sobreviventes, que indicam asserções fracas em código já exercitado.

Outra dica é escrever os novos testes pensando nas fronteiras da regra. Valores logo abaixo, exatamente no limite e logo acima costumam matar a maioria dos mutantes de equivalência.

Também vale rodar os testes de mutação com frequência, e não apenas uma vez. Assim, a equipe percebe cedo quando uma mudança enfraquece a suíte.

Se você quer aprofundar a base, o artigo sobre fundamentos dos testes de unidade é um bom ponto de partida. Para um caso real de teste que nunca falha, veja como testar data e hora no .NET com mutações deliberadas.

Considerações finais

Nem sempre o código está totalmente coberto por testes eficazes. Os testes de mutação ajudam a criar novos cenários e a prevenir bugs antes que eles cheguem à produção.

Aqui usamos um exemplo simples, apenas para apresentar a ferramenta e o conceito. Em projetos reais, porém, a quantidade de combinações cresce bastante e revela muitas lacunas.

Por fim, lembre que nem toda recomendação de um mutante sobrevivente é a melhor para a sua regra de negócio. Analise cada caso e use os testes de mutação como guia, e não como meta absoluta.

FAQ

O que é um mutante?

É uma pequena alteração proposital no código, como trocar < por <=. Se nenhum teste falhar com essa alteração ativa, o mutante sobrevive.

Mutante morto é bom ou ruim?

É bom. Um mutante morto significa que pelo menos um teste falhou e detectou o bug proposital.

Qual versão do .NET o Stryker.NET exige?

A documentação atual pede o runtime do .NET 8 ou superior para executar a ferramenta. O projeto testado não precisa mirar o .NET 8.

Onde fica o relatório HTML?

O Stryker.NET salva o relatório detalhado na pasta StrykerOutput, dentro do diretório do projeto de testes.

Preciso chegar a 100% de mutation score?

Não. A recomendação é priorizar áreas críticas, porque algumas mutações são equivalentes ao código original.

Testes de mutação substituem a cobertura de código?

Não, eles se complementam. A cobertura mostra o que roda, enquanto a mutação mostra se os testes realmente verificam o comportamento.

Glossário

Termo Significado
Teste de mutação Técnica que insere bugs propositais no código para avaliar a eficácia dos testes
Mutante Versão do código com uma pequena alteração proposital
Killed Estado do mutante detectado por pelo menos um teste com falha
Survived Estado do mutante que passou por todos os testes sem ser detectado
No coverage Estado do mutante em código que nenhum teste executa
Mutation score Percentual de mutantes detectados em relação aos mutantes válidos
Stryker.NET Ferramenta de testes de mutação para projetos .NET
xUnit Framework de testes de unidade para .NET
Theory Atributo do xUnit para executar um teste com vários conjuntos de dados
FluentAssertions Biblioteca de asserções com sintaxe fluente, como Should().BeTrue()

Fontes