TDD & Qualité

Mutation Testing : Mesurer la Vraie Qualité de vos Tests

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Mutation Testing : Mesurer la Vraie Qualité de vos Tests

Le mensonge de la couverture de code

Vous avez 90% de couverture de code. Félicitations — vos managers sont contents. Mais est-ce que vos tests sont vraiment bons ?

Considérez ce code :

export function calculateDiscount(price: number, customerType: 'vip' | 'regular'): number {
  if (customerType === 'vip') {
    return price * 0.8; // 20% de remise
  }
  return price;
}

Et ce test avec 100% de couverture :

it('applique une remise pour les clients VIP', () => {
  const result = calculateDiscount(100, 'vip');
  expect(result).toBeDefined(); // ← Le problème est ici
});

Ce test couvre 100% des lignes, mais il passerait même si la fonction retournait 0, 200, ou undefined. La couverture de code mesure quelles lignes sont exécutées — pas si elles sont correctement validées.

C'est là qu'intervient le mutation testing.

Comment fonctionne le mutation testing

Le principe est simple et élégant : un outil modifie automatiquement votre code source en introduisant de petites erreurs (les "mutants"), puis vérifie si vos tests échouent.

  • Si un test échoue → le mutant est tué (bon signe : votre test a détecté l'erreur)
  • Si tous les tests passent → le mutant survit (mauvais signe : votre test ne détecte pas cette erreur)

Les mutations typiques incluent :

  • > devient >= ou <
  • + devient -
  • true devient false
  • Suppression d'une instruction conditionnelle
  • Retourner une valeur différente (0, null, chaîne vide...)

Stryker — le standard JavaScript

Stryker est l'outil de référence pour JavaScript/TypeScript. Son installation :

npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
# Pour TypeScript
npm install --save-dev @stryker-mutator/typescript-checker

Configuration dans stryker.config.mjs :

// stryker.config.mjs
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  jest: {
    configFile: 'jest.config.ts',
  },
  checkers: ['typescript'],
  tsconfigFile: 'tsconfig.json',
  mutate: [
    'src/**/*.ts',
    '!src/**/*.test.ts',
    '!src/**/*.spec.ts',
    '!src/**/index.ts',
    '!src/types/**',
  ],
  thresholds: {
    high: 80,
    low: 60,
    break: 50, // Le build échoue si le score tombe en dessous
  },
  reporters: ['html', 'clear-text', 'progress'],
  htmlReporter: {
    fileName: 'reports/mutation/report.html',
  },
};

Lancez Stryker :

npx stryker run

Interpréter les résultats

Après un run, Stryker vous donne un rapport comme celui-ci :

Mutation score: 73.33 %

Killed  : 44
Survived: 16
Timeout : 2
No Coverage: 0

Files mutated:
  src/calculator.ts          73.33% (11/15 killed)
  src/services/order.ts      60.00% (9/15 killed)  ← À améliorer
  src/utils/validation.ts    90.00% (18/20 killed)  ✓

Un score de mutation entre 80% et 90% est généralement considéré comme excellent. Au-delà de 90%, vous entrez dans la zone des rendements décroissants — certains mutants sont très difficiles à tuer sans sur-tester.

Analyser les mutants survivants

L'analyse des survivants est la vraie valeur ajoutée. Voici comment interpréter un exemple :

// Code original
export function applyLoyaltyBonus(purchases: number): number {
  if (purchases > 10) {
    return 50;
  }
  if (purchases > 5) {
    return 20;
  }
  return 0;
}

Stryker vous indique des survivants comme :

Mutant survivant : src/loyalty.ts:3:7
  Original  : if (purchases > 10)
  Mutant    : if (purchases >= 10)

Cela signifie qu'aucun test ne vérifie le cas limite purchases === 10. La correction :

// Avant — manque les cas limites
it('retourne 50 pour un client avec beaucoup d\'achats', () => {
  expect(applyLoyaltyBonus(15)).toBe(50);
});

// Après — cas limites couverts
describe('applyLoyaltyBonus', () => {
  test.each([
    [11, 50],   // > 10
    [10, 20],   // = 10 → tombe dans la deuxième condition
    [9, 20],    // > 5
    [6, 20],    // > 5
    [5, 0],     // = 5 → pas de bonus
    [0, 0],     // aucun achat
  ])('purchases=%i → bonus=%i', (purchases, expected) => {
    expect(applyLoyaltyBonus(purchases)).toBe(expected);
  });
});

Mutations sur les opérateurs logiques

Les opérateurs logiques sont un terrain fertile pour les mutations :

// Code original
export function canAccessPremiumContent(
  user: { isPremium: boolean; isVerified: boolean; age: number }
): boolean {
  return user.isPremium && user.isVerified && user.age >= 18;
}

Les mutations probables :

  • &&||
  • >=> ou <=
  • user.isPremium!user.isPremium

Pour tuer ces mutants, vous avez besoin de tests qui vérifient chaque condition indépendamment :

describe('canAccessPremiumContent', () => {
  const validUser = { isPremium: true, isVerified: true, age: 18 };

  it('autorise l\'accès pour un utilisateur valide', () => {
    expect(canAccessPremiumContent(validUser)).toBe(true);
  });

  it('bloque si non premium', () => {
    expect(canAccessPremiumContent({ ...validUser, isPremium: false })).toBe(false);
  });

  it('bloque si non vérifié', () => {
    expect(canAccessPremiumContent({ ...validUser, isVerified: false })).toBe(false);
  });

  it('bloque si mineur (17 ans)', () => {
    expect(canAccessPremiumContent({ ...validUser, age: 17 })).toBe(false);
  });

  it('autorise à exactement 18 ans (limite incluse)', () => {
    expect(canAccessPremiumContent({ ...validUser, age: 18 })).toBe(true);
  });
});

Ignorer certains mutants intentionnellement

Parfois, un mutant survivant est acceptable. Par exemple, un log de debug n'a pas besoin d'être testé :

export function processOrder(order: Order): ProcessedOrder {
  // stryker-disable-next-line StringLiteral
  logger.debug(`Processing order ${order.id}`);
  
  // ... logique métier
  return result;
}

Vous pouvez aussi ignorer des mutateurs entiers pour certains fichiers dans la config :

// stryker.config.mjs
export default {
  mutator: {
    excludedMutations: [
      'StringLiteral', // Ignorer les mutations de chaînes de caractères
    ],
  },
  ignorePatterns: [
    'src/logger.ts',
    'src/config/*.ts',
  ],
};

Intégrer dans le pipeline CI

Le mutation testing est lent — il peut prendre 10 à 30 minutes sur une grosse codebase. La stratégie recommandée est de ne le lancer que sur les fichiers modifiés en CI :

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

on:
  pull_request:
    paths:
      - 'src/**/*.ts'

jobs:
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Nécessaire pour les diffs git

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Mutation testing sur les fichiers modifiés
        run: |
          # Récupérer les fichiers modifiés
          CHANGED_FILES=$(git diff --name-only origin/main...HEAD -- 'src/**/*.ts' | grep -v '\.test\.' | tr '\n' ',')
          
          if [ -n "$CHANGED_FILES" ]; then
            npx stryker run --mutate "${CHANGED_FILES%,}"
          else
            echo "Aucun fichier source modifié, mutation testing ignoré"
          fi

Vous pouvez aussi utiliser le flag --since de Stryker :

npx stryker run --since=origin/main

Stratégie d'adoption progressive

Ne lancez pas Stryker sur tout votre codebase d'un coup — vous aurez un score de 30% et une dette impossible à résorber. Voici une approche progressive :

Semaine 1-2 : Auditer

# Lancer sans seuil de break pour mesurer l'état actuel
npx stryker run

Mois 1 : Cibler les modules critiques

// Commencer par la logique métier pure, pas les contrôleurs
mutate: ['src/domain/**/*.ts', 'src/services/**/*.ts'],
thresholds: { break: 40 },

Mois 3-6 : Élargir et durcir

mutate: ['src/**/*.ts', '!src/infrastructure/**'],
thresholds: { break: 60 },

Conclusion

La couverture de code vous dit ce qui a été exécuté. Le mutation testing vous dit si vos assertions sont pertinentes. Ce sont deux métriques complémentaires — la première est nécessaire, la seconde est suffisante pour avoir confiance.

Le vrai bénéfice du mutation testing n'est pas le score lui-même : c'est le processus d'analyse des survivants, qui vous force à écrire des tests précis avec des valeurs de limite, des cas négatifs, et des assertions sur les valeurs exactes plutôt que sur leur existence.

Commencez par vos modules métier les plus critiques. Un score de 75% sur votre couche de domaine vaut mieux que 90% de couverture sur tout le projet.