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.