Vos tests passent. Votre code est quand même faux.

Vous avez 100 % de couverture de lignes. Chaque branche est exécutée. Chaque fonction est appelée. Puis quelqu’un change un + en - dans votre logique de tarification, lance les tests, et ils passent tous.

Ce n’est pas un problème théorique. C’est ce qui se passe quand vos tests exécutent le code sans réellement vérifier le comportement. La couverture mesure quelles lignes s’exécutent, pas quels résultats sont contrôlés. Le mutation testing comble ce manque en introduisant volontairement de petits bugs et en vérifiant que vos tests les détectent.

La question pour les équipes Rust n’est pas de savoir si le mutation testing est une bonne idée. C’est de savoir si cargo-mutants, l’outil dominant de l’écosystème, est pratique compte tenu des temps de compilation et du système de types de Rust. La réponse est oui, avec des nuances importantes.

Ce que fait réellement le mutation testing

Le mutation testing est simple en théorie. L’outil apporte une toute petite modification à votre code source, exécute votre suite de tests, et vérifie si quelque chose échoue.

Si la suite de tests échoue, le mutant est “tué”. C’est ce que vous voulez. Ça signifie que vos tests ont remarqué le bug.

Si la suite de tests passe, le mutant “survit”. Ça signifie que vos tests ont exécuté le code muté sans remarquer que quelque chose clochait. Vous avez un test faible.

Les mutations courantes incluent le remplacement d’opérateurs arithmétiques (+ devient -), l’échange d’opérateurs de comparaison (> devient >=), le remplacement de littéraux booléens (true devient false), et la suppression d’appels de fonctions qui retournent une valeur. Chaque changement est assez petit pour qu’un humain le reconnaisse comme un bug. La suite de tests devrait le reconnaître aussi.

Comment cargo-mutants fonctionne sur du code Rust

cargo-mutants est un outil de mutation testing conçu spécifiquement pour Rust. Il ne vous demande pas d’annoter vos tests ni de modifier votre système de build. Vous l’installez et vous le lancez.

cargo install cargo-mutants
cargo mutants

L’outil scanne vos fichiers source, génère des mutants en appliquant des règles de transformation à l’AST, et exécute cargo test pour chacun. Il suit les mutants qui survivent et affiche un rapport.

Voici une fonction avec un test qui a l’air solide mais ne l’est pas :

pub fn apply_discount(price: f64, rate: f64) -> f64 {
    price * (1.0 - rate)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_apply_discount() {
        let result = apply_discount(100.0, 0.2);
        // Nous avons exécuté la fonction. La couverture est de 100 %.
        // Mais nous n'avons jamais asserté le résultat.
    }
}

cargo mutants va générer un mutant qui change le * en / ou remplace 1.0 - rate par 1.0 + rate. Le test passera toujours car il ne vérifie jamais result. Le mutant survivant signale le problème.

Un vrai test qui tue le mutant ressemble à ceci :

#[test]
fn test_apply_discount() {
    assert_eq!(apply_discount(100.0, 0.2), 80.0);
    assert_eq!(apply_discount(50.0, 0.0), 50.0);
}

Maintenant chaque mutant arithmétique échoue car les assertions attrapent le mauvais résultat.

À quoi ressemble la sortie

Lancez cargo mutants et vous obtenez un résumé :

Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants

Les mutants manqués sont ceux qui ont survécu. cargo mutants écrit chacun d’eux dans mutants.out/ avec le diff et le chemin du fichier. Vous lisez le diff et ajoutez l’assertion manquante.

Les timeouts surviennent quand un mutant provoque une boucle infinie. cargo-mutants le détecte et le marque comme tué par timeout, ce qui compte comme un succès.

Les mutants non viables sont des changements qui ne compilent pas. Le système de types de Rust les rejette avant même que les tests ne s’exécutent.

Le système de types de Rust est une arme à double tranchant

En JavaScript ou Python, les outils de mutation testing peuvent remplacer presque n’importe quel opérateur et le code s’exécutera quand même. Il produira juste de mauvais résultats. En Rust, de nombreuses mutations sont attrapées par le compilateur avant même que les tests ne tournent.

Remplacez + par - sur des entiers non signés et vous pourriez obtenir un overflow, mais le code compile. Remplacez > par < dans un contexte générique et le compilateur pourrait le rejeter si les trait bounds ne supportent pas la comparaison. Supprimez un appel de fonction qui retourne une valeur attendue par l’appelant, et le compilateur génère une erreur.

Ça signifie que cargo-mutants génère moins de mutants viables que les outils équivalents dans d’autres langages. Un projet Python pourrait voir 200 mutants pour un module. Un projet Rust pourrait en voir 40. Les mutants qui compilent sont ceux qui pourraient réellement passer en production. Le système de types filtre le bruit.

Le compromis, c’est le temps de compilation. Chaque mutant viable déclenche une reconstruction. Un projet avec une suite de tests de cinq minutes pourrait passer une heure à exécuter cargo mutants.

Le coût en temps de compilation est réel

C’est la principale raison pour laquelle les équipes hésitent. Le mutation testing est embarrassingly parallel en théorie. Chaque mutant est indépendant. En pratique, le système de build de Rust ne se parallélise pas proprement sur des dizaines d’invocations du compilateur sur le même arbre source.

cargo-mutants a un flag --jobs, mais les I/O disque et le verrouillage du graphe de crates deviennent des goulots d’étranglement. Sur un runner CI typique avec deux cœurs, le job scale mal.

Vous pouvez atténuer ça. Utilisez --in-place pour éviter de copier l’arbre source pour chaque mutant. Utilisez --file ou --exclude pour cibler des modules spécifiques. Lancez le mutation testing en nocturne ou hebdomadairement, pas à chaque push.

Ce que cargo-mutants manque

Aucun outil de mutation testing n’attrape tout. cargo-mutants a des limitations spécifiques que vous devriez connaître.

Il ne mute pas les expansions de macros. Si votre logique critique vit à l’intérieur d’une macro, l’outil voit l’invocation, pas le code généré.

Il ne comprend pas l’équivalence sémantique. Certains mutants produisent un comportement différent mais toujours correct pour toutes les entrées valides. Un + 0 redondant pourrait survivre parce que les tests ne le vérifient pas, même si la mutation n’est pas un vrai bug. Vous devez trier ça manuellement.

Quand le mutation testing vaut le coût

Vous n’avez pas besoin de lancer cargo mutants à chaque commit. Vous en avez besoin quand votre suite de tests est assez grande pour que vous ne fassiez plus confiance à vos propres assertions.

Lancez-le quand un module critique a une forte couverture mais que vous avez quand même livré des bugs, ou quand un refactor a changé la logique de manière subtile et que vous voulez être sûr que les assertions sont solides.

Ne le lancez pas quand votre suite de tests est déjà flaky ou que vos temps de compilation sont le bottleneck dont tout le monde se plaint. Réglez les fondamentaux d’abord.

L’ajouter à la CI sans casser le pipeline

La configuration pratique est un job planifié, pas une gate sur chaque pull request.

Voici un workflow GitHub Actions qui s’exécute chaque semaine :

name: Mutation Testing

on:
  schedule:
    - cron: "0 3 * * 1"
  workflow_dispatch:

jobs:
  mutants:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Install cargo-mutants
        run: cargo install cargo-mutants
      - name: Run mutation testing
        run: cargo mutants --in-place
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: mutants-report
          path: mutants.out/

Le flag --in-place garde l’utilisation disque raisonnable. rust-cache réduit le temps de build initial. Le déclencheur planifié évite de bloquer les développeurs. Uploadez le rapport en artifact pour pouvoir examiner les mutants survivants sans scroller dans les logs CI.

Commencez par un seul module

Vous n’avez pas besoin de muter toute votre codebase. Choisissez un module avec une logique métier critique et un historique de bugs. Lancez cargo mutants --file src/pricing.rs. Lisez le rapport. Corrigez le test le plus faible.

Le premier run est toujours le pire. Vous trouverez des tests qui exécutent le code mais n’assertent rien. Vous trouverez des branches couvertes par des tests qui ne vérifient pas le résultat de la branche. Vous vous demanderez comment ces tests ont pu vous sembler adéquats.

C’est le but. Le mutation testing ne trouve pas de bugs dans votre code. Il trouve des bugs dans vos tests. En Rust, où le compilateur attrape déjà les erreurs évidentes, c’est exactement la boucle de feedback dont vous avez besoin.


Questions fréquemment posées

Qu’est-ce que le mutation testing ?

Le mutation testing évalue votre suite de tests en introduisant de petits bugs délibérés dans votre code source. Si vos tests échouent, le mutant est “tué”. Si vos tests passent, le mutant “survit” et vous avez un trou.

En quoi le mutation testing diffère-t-il de la couverture de code ?

La couverture mesure quelles lignes ont été exécutées. Le mutation testing mesure si vos tests détecteraient une mauvaise sortie de ces lignes. Un test peut avoir 100 % de couverture et attraper zéro mutant.

Le mutation testing est-il lent pour tous les projets Rust ?

Le coût scale avec le temps de compilation et le nombre de tests. Les petites bibliothèques peuvent finir en quelques minutes. Les gros projets workspace prennent significativement plus longtemps. Utilisez --file et --exclude pour scoper les runs à des modules spécifiques.

Puis-je ignorer les mutants faux positifs ?

Oui. cargo-mutants supporte un fichier de configuration mutants.toml pour exclure des fichiers, des fonctions, ou des types de mutation spécifiques. Utilisez ça avec parcimonie pour ne pas masquer de vrais trous dans vos tests.