.NET Testes Qualidade CI/CD xUnit Boas Práticas

Testes de mutação com Stryker no .NET: descubra se seus testes realmente testam algo

Guia prático de mutation testing com Stryker.NET: o que é mutation score, como interpretar sobreviventes, configuração por projeto.

N
Neryx Digital Architects
2 de março de 2026
11 min de leitura
220 profissionais leram
Categoria: .NET Público: Times de engenharia e produto Etapa: Aprendizado

Code coverage de 80% parece bom. Mas cobertura mede se o código foi executado — não se ele foi verificado. Um teste que chama um método mas não faz nenhum Assert conta como cobertura. Testes de mutação resolvem isso: eles modificam seu código de propósito e verificam se seus testes detectam a mudança. Se não detectam, seus testes não servem para nada.

O que é mutation testing

O Stryker injeta pequenas mutações no seu código-fonte — troca > por >=, remove um return, inverte um booleano — e roda sua suite de testes para cada mutação. Se os testes falharem, o mutante foi "morto" (bom sinal). Se os testes passarem com o código errado, o mutante "sobreviveu" — e você tem um buraco na sua cobertura.

# Instalar o Stryker.NET globalmente
dotnet tool install -g dotnet-stryker

# Ou localmente no projeto (recomendado para CI/CD)
dotnet new tool-manifest   # Cria .config/dotnet-tools.json se não existir
dotnet tool install dotnet-stryker

Primeira execução

# Rodar no diretório do projeto de testes
cd tests/OrderService.Tests
dotnet stryker

# Especificar o projeto a ser mutado
dotnet stryker \
  --project src/Application/Application.csproj \
  --test-project tests/Application.Tests/Application.Tests.csproj

# Gera relatório HTML em StrykerOutput/reports/

Entendendo o mutation score

Mutation score: 73.47% (36/49 mutantes mortos)

Arquivo: Application/UseCases/CreateOrderUseCase.cs
  ✓ [Killed]    linha 24: Removeu condição `customer.IsActive`
  ✓ [Killed]    linha 31: Trocou `> 0` por `>= 0` em validação de itens
  ✗ [Survived]  linha 45: Trocou `&&` por `||` em validação de pagamento
  ✗ [Survived]  linha 67: Removeu chamada `await _events.PublishAsync(...)`
  ~ [NoCoverage] linha 89: Nenhum teste executa este caminho

Os mutantes sobreviventes são os que importam: eles revelam onde sua lógica de negócio não está verificada pelos testes. O mutante da linha 45 diz: "você pode trocar AND por OR na validação de pagamento e seus testes nem percebem."

Configuração via stryker-config.json

// stryker-config.json — na raiz do projeto de testes
{
  "stryker-config": {
    "project": "../src/Application/Application.csproj",
    "test-projects": [
      "."
    ],

    // Threshold de qualidade — falha o build se o score cair abaixo
    "thresholds": {
      "high": 80,    // Verde: acima de 80%
      "low": 60,     // Amarelo: entre 60% e 80%
      "break": 50    // Falha o pipeline: abaixo de 50%
    },

    // Mutadores habilitados (padrão já inclui os mais relevantes)
    "mutate": [
      "src/**/*.cs",
      "!src/**/*.Generated.cs",   // Excluir código gerado
      "!src/**/Migrations/**"     // Excluir migrations
    ],

    // Reportes gerados
    "reporters": ["html", "json", "progress"],

    // Nível de log
    "log-level": "info",

    // Timeout por mutante (segundos)
    "additional-timeout": 5000,

    // Paralelismo
    "concurrency": 4,

    // Excluir métodos que não valem a pena mutar
    "ignore-methods": [
      "ToString",
      "GetHashCode",
      "Equals"
    ]
  }
}

Ignorando mutações específicas com atributos

// Ignorar mutações em código que não precisa de teste de mutação
// (ex: logging, formatação, código gerado)

// Via atributo no método
[ExcludeFromMutation("Logging não impacta lógica de negócio")]
private void LogOrderDetails(Order order)
{
    _logger.LogInformation("Pedido {Id} criado: {Total}", order.Id, order.Total);
}

// Via comentário inline (mais granular)
var message = $"Pedido {order.Id} criado"; // Stryker.Ignore
_logger.LogInformation(message);

// Via stryker-config.json para namespaces inteiros
// "ignore-mutations": ["string", "interpolatedstring"]

Exemplo prático: encontrando bugs com mutação

// Código de produção com bug potencial
public class DiscountCalculator
{
    public decimal Calculate(decimal price, int quantity, CustomerTier tier)
    {
        var discount = tier switch
        {
            CustomerTier.Bronze => 0.05m,
            CustomerTier.Silver => 0.10m,
            CustomerTier.Gold => 0.15m,
            _ => 0m
        };

        // Bug potencial: E se quantity for 0?
        if (quantity > 10)
            discount += 0.05m; // desconto adicional por volume

        return price * quantity * (1 - discount);
    }
}

// Teste "com cobertura" mas fraco
[Fact]
public void Calculate_GoldCustomer_AppliesDiscount()
{
    var calc = new DiscountCalculator();
    var result = calc.Calculate(100m, 1, CustomerTier.Gold);
    Assert.True(result > 0); // ← Só verifica que não é zero. Inútil.
}

// Stryker vai gerar mutante: `quantity > 10` → `quantity >= 10`
// O teste passa com o mutante? SIM — Assert.True(result > 0) não detecta.
// Mutante sobrevive. Stryker revela o problema.

// Teste forte que o Stryker vai matar todos os mutantes relevantes
[Theory]
[InlineData(CustomerTier.Bronze, 1, 100m, 95.00m)]    // 5% de desconto
[InlineData(CustomerTier.Silver, 1, 100m, 90.00m)]    // 10% de desconto
[InlineData(CustomerTier.Gold, 1, 100m, 85.00m)]      // 15% de desconto
[InlineData(CustomerTier.Gold, 11, 100m, 1100m * 0.80)] // 15% + 5% volume
[InlineData(CustomerTier.Bronze, 10, 100m, 950m)]     // 5%, sem volume
[InlineData(CustomerTier.Bronze, 11, 100m, 1045m)]    // 5% + 5% volume (>10)
public void Calculate_ReturnsExactExpectedValue(
    CustomerTier tier, int quantity, decimal price, decimal expected)
{
    var calc = new DiscountCalculator();
    var result = calc.Calculate(price, quantity, tier);
    result.Should().BeApproximately(expected, 0.01m);
}

Integração com GitHub Actions

# .github/workflows/mutation-tests.yml
name: Mutation Tests

on:
  pull_request:
    branches: [main]
    paths:
      - "src/Application/**"
      - "tests/Application.Tests/**"

jobs:
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.x

      - name: Instalar Stryker
        run: dotnet tool restore

      - name: Rodar testes de mutação
        run: |
          cd tests/Application.Tests
          dotnet stryker \
            --reporter "json" \
            --reporter "markdown" \
            --output StrykerOutput

      - name: Upload relatório de mutação
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: stryker-report
          path: tests/Application.Tests/StrykerOutput/

      - name: Comentar no PR com resultado
        uses: actions/github-script@v7
        if: always()
        with:
          script: |
            const fs = require('fs');
            const reportPath = 'tests/Application.Tests/StrykerOutput/reports/mutation-report.md';
            if (fs.existsSync(reportPath)) {
              const report = fs.readFileSync(reportPath, 'utf8');
              await github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: `## 🧬 Resultado dos Testes de Mutação\n\n${report}`
              });
            }

Estratégia realista de adoção

Mutation testing em todo o projeto é lento. A estratégia inteligente é aplicá-lo seletivamente onde importa:

Camada Vale usar Stryker? Por quê
Domain (regras de negócio) ✅ Essencial Bugs aqui são críticos e invisíveis
Application (casos de uso) ✅ Importante Orquestra a lógica principal
Validators (FluentValidation) ✅ Muito útil Regras de validação são fáceis de mutar
Infrastructure (repos, adapters) ⚠️ Opcional Geralmente coberto por testes de integração
Controllers / Minimal APIs ❌ Desnecessário Só fazem mapeamento, sem lógica
Migrations, código gerado ❌ Excluir Não tem lógica testável

Comece com score de 60% como threshold de CI/CD. Aumente para 70% após 1 sprint de melhoria de testes. Não tente chegar a 100% — alguns mutantes equivalentes (que não mudam o comportamento real) são difíceis de matar e não valem o esforço.


Testes de qualidade são o que garante que você pode refatorar e evoluir um sistema com confiança. Se você quer implantar uma cultura de qualidade no seu time .NET, a Neryx pode ajudar.

Quer transformar esse aprendizado em plano de ação?

Se o tema deste artigo se parece com o momento do seu time, podemos ajudar a decidir o próximo passo com clareza.

Falar com um especialista

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.