Deine Tests bestehen. Dein Code ist trotzdem falsch.

Du hast 100% Line Coverage. Jeder Branch wird getroffen. Jede Funktion wird aufgerufen. Dann ändert jemand ein + in ein - in deiner Pricing-Logik, führt die Tests aus, und alle bestehen.

Das ist kein theoretisches Problem. Es passiert, wenn deine Tests den Code ausführen, aber das Verhalten nicht wirklich prüfen. Coverage misst, welche Zeilen laufen, nicht welche Outputs geprüft werden. Mutation Testing schließt diese Lücke, indem es absichtlich kleine Bugs einführt und verifiziert, dass deine Tests sie finden.

Die Frage für Rust-Teams ist nicht, ob Mutation Testing eine gute Idee ist. Es ist die Frage, ob cargo-mutants, das dominierende Tool im Ökosystem, angesichts von Rusts Compile-Zeiten und Typsystem praktikabel ist. Die Antwort ist ja, mit Einschränkungen, die wichtig sind.

Was Mutation Testing eigentlich macht

Mutation Testing ist konzeptionell einfach. Das Tool nimmt eine winzige Änderung an deinem Source Code vor, führt deine Test-Suite aus und prüft, ob etwas fehlschlägt.

Wenn die Test-Suite fehlschlägt, wird der Mutant “killed”. Das ist genau das, was du willst. Es bedeutet, dass deine Tests den Bug bemerkt haben.

Wenn die Test-Suite besteht, “survived” der Mutant. Das bedeutet, dass deine Tests den mutierten Code ausgeführt haben, aber nicht bemerkt haben, dass etwas falsch war. Du hast einen schwachen Test.

Gängige Mutationen umfassen das Ersetzen von arithmetischen Operatoren (+ wird -), das Vertauschen von Vergleichsoperatoren (> wird >=), das Ersetzen von booleschen Literalen (true wird false) und das Löschen von Funktionsaufrufen, die Werte zurückgeben. Jede Änderung ist so klein, dass ein Mensch sie als Bug erkennen würde. Die Test-Suite sollte sie auch erkennen.

Wie cargo-mutants in Rust-Code arbeitet

cargo-mutants ist ein Mutation-Testing-Tool, das speziell für Rust entwickelt wurde. Es verlangt nicht, dass du deine Tests annotierst oder dein Build-System änderst. Du installierst es und führst es aus.

cargo install cargo-mutants
cargo mutants

Das Tool scannt deine Source Files, generiert Mutanten, indem es Transformationsregeln auf den AST anwendet, und führt für jeden cargo test aus. Es verfolgt, welche Mutanten überleben, und gibt einen Report aus.

Hier ist eine Funktion mit einem Test, der solide aussieht, aber es nicht ist:

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);
        // We ran the function. Coverage is 100%.
        // But we never asserted the result.
    }
}

cargo mutants wird einen Mutanten generieren, der das * in / ändert oder 1.0 - rate durch 1.0 + rate ersetzt. Der Test wird trotzdem bestehen, weil er result nie prüft. Der überlebende Mutant markiert das Problem.

Ein echter Test, der den Mutanten tötet, sieht so aus:

#[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);
}

Jetzt schlägt jeder arithmetische Mutant fehl, weil die Assertions den falschen Output erkennen.

Wie die Ausgabe aussieht

Führe cargo mutants aus und du bekommst eine Zusammenfassung:

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

Missed Mutanten sind die, die überlebt haben. cargo mutants schreibt jeden nach mutants.out/ mit dem Diff und dem Dateipfad. Du liest den Diff und fügst die fehlende Assertion hinzu.

Timeouts passieren, wenn ein Mutant eine Endlosschleife verursacht. cargo-mutants erkennt das und markiert ihn als durch Timeout getötet (killed by timeout), was als Erfolg zählt.

Unviable Mutanten sind Änderungen, die nicht kompilieren. Rusts Typsystem lehnt sie ab, bevor die Tests überhaupt laufen.

Rusts Typsystem ist ein zweischneidiges Schwert

In JavaScript oder Python können Mutation-Testing-Tools fast jeden Operator ersetzen, und der Code läuft trotzdem. Er produziert nur falsche Ergebnisse. In Rust werden viele Mutationen vom Compiler erfasst, bevor die Tests überhaupt laufen.

Ersetze + durch - bei Unsigned Integers und du bekommst vielleicht einen Overflow, aber der Code kompiliert. Ersetze > durch < in einem generischen Kontext und der Compiler lehnt es vielleicht ab, wenn die Trait Bounds den Vergleich nicht unterstützen. Lösche einen Funktionsaufruf, der einen Wert zurückgibt, den der Caller erwartet, und der Compiler bricht mit einem Fehler ab.

Das bedeutet, dass cargo-mutants weniger viable Mutanten generiert als äquivalente Tools in anderen Sprachen. Ein Python-Projekt könnte 200 Mutanten für ein Modul sehen. Ein Rust-Projekt könnte 40 sehen. Die Mutanten, die kompilieren, sind die, die tatsächlich in die Produktion gelangen könnten. Das Typsystem filtert Rauschen heraus.

Der Trade-off ist Compile-Time. Jeder viable Mutant löst einen Rebuild aus. Ein Projekt mit einer fünfminütigen Test-Suite könnte eine Stunde damit verbringen, cargo mutants auszuführen.

Die Compile-Time-Steuer ist real

Das ist der Hauptgrund, warum Teams zögern. Mutation Testing ist theoretisch embarrassingly parallel. Jeder Mutant ist unabhängig. In der Praxis parallelisiert Rusts Build-System nicht sauber über Dutzende von Compiler-Aufrufen auf dem gleichen Source Tree.

cargo-mutants hat ein --jobs-Flag, aber Disk-I/O und Crate-Graph-Locking werden zu Bottlenecks. Auf einem typischen CI-Runner mit zwei Cores skaliert der Job schlecht.

Du kannst das abmildern. Nutze --in-place, um zu vermeiden, dass der Source Tree für jeden Mutanten kopiert wird. Nutze --file oder --exclude, um bestimmte Module anzusprechen. Führe Mutation Testing nächtlich oder wöchentlich aus, nicht bei jedem Push.

Was cargo-mutants verpasst

Kein Mutation-Testing-Tool findet alles. cargo-mutants hat spezifische Einschränkungen, die du kennen solltest.

Es mutiert keine Macro-Expansions. Wenn deine kritische Logik in einem Macro lebt, sieht das Tool den Aufruf, nicht den generierten Code.

Es versteht keine semantische Äquivalenz. Manche Mutanten produzieren ein anderes Verhalten, das aber für alle validen Inputs trotzdem korrekt ist. Ein redundantes + 0 könnte überleben, weil die Tests es nicht interessiert, auch wenn die Mutation kein echter Bug ist. Du musst diese manuell triagieren.

Wann sich Mutation Testing lohnt

Du musst cargo mutants nicht bei jedem Commit ausführen. Du brauchst es, wenn deine Test-Suite groß genug ist, dass du deinen eigenen Assertions nicht mehr traust.

Führe es aus, wenn ein kritisches Modul hohe Coverage hat, aber du trotzdem Bugs darin ausgeliefert hast, oder wenn ein Refactor die Logik auf subtile Weise geändert hat und du dir sicher sein willst, dass die Assertions stringent sind.

Führe es nicht aus, wenn deine Test-Suite schon flaky ist oder deine Compile-Zeiten der Bottleneck sind, über den sich alle beschweren. Behebe zuerst die Grundlagen.

In CI integrieren, ohne die Pipeline zu brechen

Der praktische Aufbau ist ein geplanter Job, kein Gate bei jedem Pull Request.

Hier ist ein GitHub-Actions-Workflow, der wöchentlich läuft:

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/

Das --in-place-Flag hält die Disk-Nutzung vertretbar. rust-cache reduziert die initiale Build-Zeit. Der geplante Trigger verhindert, dass Entwickler blockiert werden. Lade den Report als Artifact hoch, damit du überlebende Mutanten reviewen kannst, ohne durch CI-Logs zu scrollen.

Fange mit einem Modul an

Du musst nicht deine gesamte Codebase mutieren. Wähle ein Modul mit business-kritischer Logik und einer Historie von Bugs. Führe cargo mutants --file src/pricing.rs aus. Lies den Report. Behebe den schwächsten Test.

Der erste Lauf ist immer der schlimmste. Du wirst Tests finden, die den Code ausführen, aber nichts asserten. Du wirst Branches finden, die von Tests abgedeckt sind, die aber das Branch-Ergebnis nicht prüfen. Du wirst dich fragen, wie diese Tests jemals adäquat erschienen sind.

Darum geht es. Mutation Testing findet keine Bugs in deinem Code. Es findet Bugs in deinen Tests. In Rust, wo der Compiler die offensichtlichen Fehler schon findet, ist das genau der Feedback Loop, den du brauchst.


Häufig gestellte Fragen

Was ist Mutation Testing?

Mutation Testing bewertet deine Test-Suite, indem es absichtlich kleine, bewusste Bugs in deinen Source Code einführt. Wenn deine Tests fehlschlagen, wird der Mutant “killed”. Wenn deine Tests bestehen, “survived” der Mutant und du hast eine Lücke.

Wie unterscheidet sich Mutation Testing von Code Coverage?

Coverage misst, welche Zeilen ausgeführt wurden. Mutation Testing misst, ob deine Tests falsche Outputs dieser Zeilen erkennen würden. Ein Test kann 100% Coverage haben und null Mutanten finden.

Ist Mutation Testing für alle Rust-Projekte langsam?

Die Kosten skalieren mit Compile-Zeit und Test-Count. Kleine Libraries können in Minuten fertig sein. Große Workspace-Projekte dauern deutlich länger. Nutze --file und --exclude, um Läufe auf bestimmte Module einzugrenzen.

Kann ich False-Positive-Mutanten ignorieren?

Ja. cargo-mutants unterstützt eine mutants.toml-Konfigurationsdatei, um Files, Funktionen oder spezifische Mutation Types auszuschließen. Nutze das sparsam, damit du echte Test-Lücken nicht verdeckst.