La couverture de code à 85% et tous les tests au vert. Le client est rassuré, le pipeline est vert, tout le monde est content. Et pourtant, lors d'une revue de code sur une mission il y a deux ans, j'ai trouvé une condition critique — une règle métier sur la validation d'un plafond de commande — qui était complètement fantôme : couverte à 100%, mais dont la suppression totale n'aurait fait échouer aucun test. La couverture mentait. Depuis, j'utilise Stryker.NET sur tous mes projets, et j'ai arrêté de faire confiance aux pourcentages de coverage seuls.

Ce que les mutation tests font que la couverture ne fait pas

La couverture de code répond à une seule question : cette ligne de code est-elle exécutée par au moins un test ? C'est utile, mais insuffisant. Une ligne peut être traversée par un test qui n'assert rien du tout — ou qui assert quelque chose de si superficiel qu'une modification du comportement réel n'y changerait rien.

Les tests de mutation répondent à une question radicalement différente : si j'introduis un bug dans ce code, est-ce qu'un test échoue ? Stryker.NET modifie automatiquement votre code source en appliquant des mutations : il remplace > par >=, inverse des conditions booléennes, supprime des instructions return, change des valeurs de chaînes... et vérifie si votre suite de tests détecte ces changements. Un mutant non détecté — un survivant — est un angle mort dans votre filet de sécurité.

"Un test qui passe au vert sans jamais avoir été rouge sur un bug est un spectateur, pas un gardien. Stryker vous montre exactement combien de spectateurs se cachent dans votre suite de tests."

Installer et configurer Stryker.NET

L'installation est simple via l'outil global dotnet. Sur mes missions, je l'installe en local dans le projet pour garantir la reproductibilité entre les membres de l'équipe et le pipeline CI :

# Installation de l'outil dotnet-stryker
dotnet tool install --local dotnet-stryker

# Ou en global si vous travaillez sur plusieurs projets
dotnet tool install --global dotnet-stryker

# Vérifier la version
dotnet stryker --version

Ensuite, je crée un fichier stryker-config.json à la racine du projet de tests. La configuration est la clé pour que Stryker soit utile sans être prohibitivement lent :

{
  "stryker-config": {
    "project": "Edenred.Smarter.Client.Bff.Application.csproj",
    "test-projects": [
      "../../tests/Edenred.Smarter.Client.Bff.Application.Tests/Edenred.Smarter.Client.Bff.Application.Tests.csproj"
    ],
    "reporters": ["html", "json", "progress"],
    "mutation-level": "Standard",
    "thresholds": {
      "high": 85,
      "low": 75,
      "break": 60
    },
    "ignore-mutations": ["string", "linq"],
    "mutate": [
      "src/Domain/**/*.cs",
      "src/Application/**/*.cs",
      "!src/Application/Migrations/**/*.cs"
    ],
    "since": {
      "enabled": false
    }
  }
}

Deux points importants dans cette config : le break threshold à 60 fera échouer le build CI si le mutation score descend en dessous. Et les exclusions ignore-mutations sur les chaînes et LINQ évitent une explosion de mutants sans valeur sur des transformations de texte.

Un exemple concret : le test zombie révélé

Voici le type de situation que Stryker détecte systématiquement sur mes missions. Considérons un service de validation de commande :

// Code de production
public class OrderValidator
{
    public bool IsValid(Order order)
    {
        if(order.TotalAmount <= 0)
        {
            return false;
        }

        if(order.Lines.Count == 0)
        {
            return false;
        }

        return order.TotalAmount <= order.Client.MaxOrderAmount;
    }
}

// Test "couvert" — mais est-il vraiment utile ?
[Fact]
public void IsValid_WhenOrderIsValid_ReturnsTrue()
{
    var order = new Order
    {
        TotalAmount = 500,
        Lines = new List<OrderLine> { new() },
        Client = new Client { MaxOrderAmount = 1000 }
    };

    var result = new OrderValidator().IsValid(order);

    // PROBLÈME : on assert juste que c'est true
    // Si on change <= en < sur la dernière condition, le test passe quand même
    result.Should().BeTrue();
}

// Test fort — Stryker ne trouve pas de survivant
[Theory]
[InlineData(999, 1000, true)]   // juste en dessous du plafond
[InlineData(1000, 1000, true)]  // exactement au plafond
[InlineData(1001, 1000, false)] // dépassement d'un euro
[InlineData(0, 1000, false)]    // montant nul
public void IsValid_BorderCases_RespectsMaxAmount(
    decimal amount, decimal maxAmount, bool expected)
{
    var order = new Order
    {
        TotalAmount = amount,
        Lines = new List<OrderLine> { new() },
        Client = new Client { MaxOrderAmount = maxAmount }
    };

    new OrderValidator().IsValid(order).Should().Be(expected);
}

Le premier test couvre la méthode à 100%. Stryker, lui, va remplacer <= par < sur la dernière condition — et le test survivra, car 500 < 1000 est toujours vrai. Le mutant a survécu. Avec le second test paramétré, le cas [InlineData(1000, 1000, true)] tuera ce mutant : si Stryker remplace <= par <, alors 1000 < 1000 est faux, et le test échoue. Mutant tué.

Score de mutation vs couverture : comprendre la différence

Sur un projet enterprise type que j'ai repris en mission l'an dernier, le tableau de bord initial était flatteur : 87% de couverture. Après le premier run Stryker, la réalité était plus nuancée :

La couverture donnait une fausse confiance. Le score de mutation sur la couche domaine — la plus critique — était le seul chiffre qui importait. En deux sprints, on a renforcé les tests sur les handlers pour monter à 80% de mutation score sur Application, en ciblant exclusivement les cas de bord révélés par les rapports HTML de Stryker.

Intégrer Stryker dans le pipeline CI/CD

La configuration CI que j'utilise sur mes missions Azure DevOps distingue deux niveaux d'exécution. Les tests unitaires classiques tournent sur chaque PR. Stryker, plus coûteux, tourne sur les branches de release et en nightly :

# azure-pipelines.yml — extrait Stryker
- stage: MutationTests
  displayName: 'Mutation Testing (Stryker)'
  condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), eq(variables['Build.Reason'], 'Schedule'))
  jobs:
  - job: Stryker
    timeoutInMinutes: 30
    steps:
    - task: UseDotNet@2
      inputs:
        version: '9.0.x'
    - script: dotnet tool restore
      displayName: 'Restore dotnet tools'
    - script: |
        dotnet stryker \
          --output $(Build.ArtifactStagingDirectory)/stryker \
          --reporter html \
          --reporter json
      workingDirectory: tests/MyProject.Tests
      displayName: 'Run Stryker mutation tests'
    - task: PublishBuildArtifacts@1
      inputs:
        pathToPublish: '$(Build.ArtifactStagingDirectory)/stryker'
        artifactName: 'StrykerReport'

L'artifact HTML est publié et accessible depuis le résumé du build. En pratique, l'équipe ouvre ce rapport une fois par sprint pour identifier les zones les plus exposées et prioriser les renforcements de tests. Ce n'est pas une contrainte quotidienne — c'est un outil d'audit régulier.

Les pièges classiques à éviter

Stryker peut devenir contre-productif si on l'utilise sans discernement. Voici ce que j'ai appris à éviter :

"La couverture vous dit que vous avez regardé. Le mutation score vous dit que vous avez vu."

Ce que Stryker a changé dans mes missions

Concrètement, depuis que j'intègre Stryker dans mes missions freelance, j'observe deux effets durables. D'abord, les développeurs junior de l'équipe apprennent à écrire de meilleurs tests : quand Stryker signale un survivant, on remonte ensemble au code, on comprend quelle assertion manquait, on l'ajoute. C'est le meilleur outil pédagogique que j'ai trouvé pour expliquer pourquoi [Theory] avec des cas limites vaut infiniment mieux qu'un seul [Fact] avec des données au milieu de la plage.

Ensuite, lors des revues de code, la conversation change. On ne parle plus de "est-ce que ce code est testé ?" mais "est-ce que ces tests tuent les mutations critiques ?". C'est une subtilité, mais elle déplace le curseur de la couverture-décoration vers la robustesse-réelle.

Vous voulez des tests qui testent vraiment ?

J'intègre Stryker.NET et les pratiques de mutation testing dans mes missions Lead Dev .NET freelance depuis La Rochelle. On peut en parler.

✉ Me contacter