Imposer un seul score de mutation à l’ensemble de votre codebase est un excellent moyen de faire détester les tests à votre équipe.

Lancez PIT ou Stryker sur un repo typique et vous verrez le même schéma : les modules d’authentification atteignent 40%, les utilitaires de chaînes de caractères frôlent les 95%, et votre couche ORM se situe quelque part dans les 60%. La réaction de réflexe consiste à définir une barrière globale à, disons, 70% et bloquer chaque PR qui descend en dessous. Deux sprints plus tard, quelqu’un désactive la vérification dans la CI et l’attribue à des « mutateurs flaky ».

Le vrai problème n’est pas les outils. C’est de prétendre que tout le code a le même rayon d’explosion quand un mutant survit.

Ce que mesure réellement le mutation testing

La couverture de code vous dit quelles lignes ont été exécutées. Le mutation testing vous dit si vos tests remarqueraient que ces lignes ont changé.

Un framework de mutation introduit de petits défauts (mutants) dans votre source. Il peut inverser un > en <, supprimer un appel de méthode, ou changer une valeur de retour. Si votre suite de tests détecte le changement, le mutant est tué. Si elle passe quand même, le mutant survit. Votre score de mutation est le pourcentage de mutants tués.

Un mutant survivant dans une comparaison de hash de mot de passe est un bug de sécurité en attente de production. Un mutant survivant dans un helper capitalizeFirstLetter est, au pire, une étiquette d’interface légèrement étrange.

Les traiter de la même manière est l’erreur que commettent les équipes.

Pourquoi l’authentification mérite une barrière à 90%+

Le code d’authentification et d’autorisation a deux propriétés qui en font un candidat idéal pour un mutation testing agressif.

Premièrement, la logique est généralement discrète et ressemble à une state machine. Le token a-t-il expiré ? Le rôle fait-il partie de l’ensemble autorisé ? La signature a-t-elle été vérifiée ? Chaque branche a une implication de sécurité claire, et chacune devrait être testée.

Deuxièmement, le coût d’un mutant survivant est catastrophique. Un seul booléen inversé dans une vérification de rôle peut exposer des endpoints admin. Un not manqué dans une routine de validation de token peut accepter des JWT forgés. Ce ne sont pas des cas théoriques. Les bases de données CVE regorgent de contournements d’authentification causés par des erreurs de logique que le mutation testing aurait détectées.

Chez Sentry, nous imposons un score de mutation de 90% sur tout ce qui se trouve dans les modules authn/ et authz/. Tout ce qui est en dessous échoue à la CI. Pas de dérogations, pas de « on corrigera ça au prochain sprint ». Le module est suffisamment petit pour que cela soit réalisable sans écrire 40 lignes de test pour chaque ligne de code de production.

Voici à quoi cela ressemble en pratique. Voici une routine de validation JWT simplifiée :

import time
from typing import Optional

def verify_token(token: dict, expected_aud: str, leeway: int = 30) -> bool:
    now = time.time()

    if token.get("aud") != expected_aud:
        return False

    exp = token.get("exp")
    if exp is not None and now > exp + leeway:
        return False

    return True

Un framework de mutation pourrait inverser > en >= dans la vérification d’expiration. Sans un test qui utilise un token expirant exactement à now + leeway, ce mutant survit. Cela signifie que vos tests ne vérifient pas réellement la limite. À 90% de couverture de mutation, ce test existe.

Le code utilitaire peut se contenter de 60%

Vos StringUtils, DateHelpers et MathExtensions sont à l’opposé du spectre.

Ces modules ont tendance à être purs, largement réutilisés et faciles à analyser. Un mutant survivant dans truncate(str, maxLen) qui change > en >= pourrait couper un caractère de plus. C’est une bizarrerie d’interface, pas un incident de sécurité.

Le calcul risque/bénéfice change. Ces modules ont souvent des dizaines de petites fonctions. Viser 90% de couverture de mutation signifie écrire des tests pour chaque variante off-by-one dans padLeft. Les tests deviennent plus longs que le code qu’ils protègent, et le fardeau de maintenance commence à dépasser la valeur.

Nous fixons un plancher de 60% pour les modules utilitaires. Cela détecte les lacunes évidentes (vérifications de null manquantes, valeurs de retour erronées) sans forcer l’équipe à tester exhaustivement chaque permutation de découpage de chaîne.

La clé est d’être honnête sur ce que signifie 60%. Cela signifie « nous avons testé les cas courants et les échecs évidents ». Cela ne signifie pas « ce code n’a pas d’importance ». Si une fonction utilitaire est utilisée dans un chemin sensible à la sécurité, elle hérite du seuil plus élevé de son consommateur.

Le juste milieu : la logique métier

La majeure partie de votre code se situe entre ces deux extrêmes. Traitement des paiements, validation des données, orchestration des workflows. Ces modules affectent la justesse et la confiance des utilisateurs, mais un seul mutant survivant ne donnera généralement pas votre base de données à un attaquant.

Nous utilisons un système à niveaux :

Type de moduleSeuil de mutationJustification
AuthN / AuthZ90%Haut rayon d’explosion, logique discrète
Logique métier75%Critique pour la justesse, complexité modérée
Utilitaires / helpers60%Faible rayon d’explosion, forte réutilisation, fonctions simples
Généré / boilerplateExcludedNe testez pas le code que vous n’avez pas écrit

Ce n’est pas une règle rigide. Un module de calcul de paiement pourrait monter à 85%. Un helper JSON largement utilisé pourrait passer à 75% s’il est consommé par du code d’authentification. Les niveaux sont un point de départ, pas une cage.

Comment implémenter des barrières de mutation à niveaux

Stryker et PIT supportent tous deux la configuration par module. Voici comment nous l’intégrons dans un projet Python en utilisant mutmut avec une configuration personnalisée :

# mutation_config.py
THRESHOLDS = {
    "src/authn/": 90,
    "src/authz/": 90,
    "src/billing/": 85,
    "src/workflows/": 75,
    "src/utils/": 60,
}

EXCLUDE_PATHS = [
    "src/generated/",
    "src/migrations/",
]

Dans la CI, un petit script lit cette configuration et lance le testeur de mutation par module :

#!/usr/bin/env bash
# ci/check-mutation.sh
set -e

python -m mutmut run --paths-to-mutate=src/authn/
python -m mutmut results || true
python -m mutmut run --paths-to-mutate=src/utils/
python -m mutmut results || true

python ci/verify_thresholds.py

Le script de vérification vérifie le score de chaque module par rapport à son seuil. Si src/authn/ atteint 87%, le build échoue avec un message clair : authn/ scored 87%, threshold is 90%.

Pour Stryker (JavaScript/TypeScript), utilisez stryker.conf.js avec des groupes de mutateurs :

// stryker.conf.js
module.exports = {
  thresholds: {
    high: 90,
    low: 75,
    break: null, // we handle this per-module
  },
  mutate: [
    "src/auth/**/*.ts",
    "src/billing/**/*.ts",
    "src/utils/**/*.ts",
  ],
  ignorePatterns: ["src/generated/**"],
};

Nous enveloppons Stryker dans un script qui l’exécute trois fois avec différents globs de chemins et impose le seuil par répertoire après chaque exécution. C’est un peu lourd, mais ça fonctionne.

Le piège de la course aux 100%

Certaines équipes voient le mutation testing comme un jeu à gagner. Elles écrivent des tests qui existent uniquement pour tuer des mutants, pas pour vérifier le comportement.

Le pire exemple consiste à tester qu’un message d’exception spécifique contient une sous-chaîne, juste pour qu’un mutant qui change le texte du message soit tué. Ce test n’ajoute aucune valeur. Il ne vérifie pas que l’exception est levée au bon moment, ou que le bon type est levé. Il vérifie seulement la chaîne.

Si vous vous surprenez à écrire des tests purement pour augmenter un pourcentage, vous avez inversé le but. Le mutation testing est un outil de diagnostic, pas un classement. Le score vous indique où chercher. Il ne vous dit pas quand vous avez fini.

Ce que nous avons appris à nos dépens

Nous avons commencé avec une barrière globale à 80%. En un mois, trois équipes l’avaient désactivée dans des branches de fonctionnalités « temporairement ». Deux de ces désactivations temporaires sont devenues permanentes.

Le problème n’était pas le chiffre. C’est que 80% était trop bas pour le code d’authentification (nous avons manqué un bug de vérification de rôle qui est arrivé en staging) et trop élevé pour un module utilitaire de 4 000 lignes (l’équipe a passé deux semaines à écrire des tests pour des variantes de isValidEmail).

Après avoir adopté des niveaux, l’adoption a tenu. Les équipes d’authentification ont accepté la barre à 90% parce que la portée était délimitée. Les équipes plateforme ont accepté 60% pour les utilitaires parce que c’était réalisable sans folie. L’approche par niveaux a transformé le mutation testing d’une punition en une conversation sur le risque.

Par où commencer

Si vous introduisez le mutation testing dans une codebase existante, ne définissez aucune barrière la première semaine. Lancez l’outil, regardez les scores, et demandez-vous : où un mutant survivant ferait-il le plus de dégâts ?

Commencez par l’authentification. Fixez-y 90%, rendez-le vert, et prouvez la valeur. Étendez-vous à la logique métier une fois que l’équipe fait confiance au signal. Gardez les utilitaires à une barre plus basse ou excluez-les entièrement jusqu’à ce que vous ayez pris l’habitude.

Et souvenez-vous : un score de 60% avec des tests honnêtes bat un score de 95% avec des tests écrits pour tromper le mutateur. Le but est de détecter de vrais bugs, pas d’impressionner votre tableau de bord de métriques.

Si vous voulez essayer vous-même, mutmut pour Python et Stryker pour JavaScript supportent tous deux les patterns par répertoire décrits ci-dessus. Commencez petit. Un module d’authentification. Une semaine. Voyez ce qui survit.