Deine Tests laufen durch. Dein Coverage-Report zeigt 87 %. Aber dein Mutation-Score liegt bei 40 %, und die Hälfte deiner Mutanten lebt noch.

Diese 40 % bedeuten nicht, dass dein Code kaputt ist. Sie bedeuten, dass deine Tests es sind. Coverage misst, welche Zeilen während eines Testlaufs ausgeführt wurden. Mutation-Testing misst, ob deine Tests es merken würden, wenn diese Zeilen anfangen würden, das Falsche zu tun. Ein Mutation-Score von 40 % bedeutet, dass 60 % der Bugs, die in deinen Code hätten eingeschleust werden können, direkt durch die CI geschlüpft wären.

Was ein überlebender Mutant wirklich ist

Ein überlebender Mutant ist ein kleiner, künstlicher Bug, den deine Tests nicht erwischt haben.

Mutation-Testing-Tools nehmen deinen Quellcode und wenden eine Reihe vordefinierter Transformationen an – eine nach der anderen. Sie können ein > in ein >= umwandeln, ein + in ein - ändern oder eine boolesche Bedingung durch true ersetzen. Jede transformierte Version deines Codes ist ein Mutant. Das Tool führt deine Test-Suite gegen jeden Mutanten aus. Schlägt irgendein Test fehl, wird der Mutant “getötet” (“killed”). Laufen alle Tests durch, “überlebt” der Mutant (“survives”).

Ein überlebender Mutant bedeutet eins von zwei Dingen. Entweder verifizieren deine Tests das Verhalten, das der Mutant zerstört hat, nicht wirklich – oder der Mutant ist “äquivalent” (die Transformation erzeugt semantisch identischen Code, was ein bekanntes, schwieriges Problem im Mutation-Testing ist).

Die meisten Überlebenden sind nicht äquivalent. Die meisten sind lauernde Bugs.

Ein konkretes Beispiel: Der Passwort-Validator

Hier ist eine Funktion, die prüft, ob ein Passwort die Richtlinien erfüllt:

// password.js
function isValidPassword(password) {
  if (password.length < 8) {
    return false;
  }
  if (!/[A-Z]/.test(password)) {
    return false;
  }
  if (!/[0-9]/.test(password)) {
    return false;
  }
  return true;
}

module.exports = { isValidPassword };

Und hier ist eine Test-Suite, die 100 % Line Coverage liefert:

// password.test.js
const { isValidPassword } = require('./password');

test('accepts a valid password', () => {
  expect(isValidPassword('Hello1')).toBe(true);
});

test('rejects a short password', () => {
  expect(isValidPassword('Hi1')).toBe(false);
});

test('rejects a password without uppercase', () => {
  expect(isValidPassword('hello1')).toBe(false);
});

test('rejects a password without a digit', () => {
  expect(isValidPassword('Hellooo')).toBe(false);
});

Moment. isValidPassword('Hello1') gibt true zurück, aber 'Hello1' ist nur sechs Zeichen lang. Die erste Prüfung sollte es ablehnen. Der Test ist falsch, aber er läuft durch, weil der Test selbst das falsche Verhalten erwartet.

Ein Mutation-Testing-Tool wie Stryker würde das auffliegen lassen. Eine seiner Mutationen würde das < in der Längenprüfung in <= umwandeln. Dieser Mutant würde überleben, weil die bestehenden Tests die Grenze bei 8 Zeichen nicht wirklich verifizieren. Eine andere Mutation könnte den gesamten ersten if-Block löschen. Auch dieser Mutant würde überleben, weil die Tests kein acht Zeichen langes Passwort ohne Großbuchstaben oder Ziffer enthalten. Die obere Grenze der Länge wird nie in Kombination mit den anderen Regeln getestet.

Hier ist eine Test-Suite, die diese Mutanten tatsächlich tötet:

// password.test.js
const { isValidPassword } = require('./password');

test('rejects password shorter than 8 chars', () => {
  expect(isValidPassword('Hello1')).toBe(false);
});

test('accepts password exactly 8 chars with uppercase and digit', () => {
  expect(isValidPassword('Hello1!@')).toBe(true);
});

test('rejects password without uppercase', () => {
  expect(isValidPassword('hello1!@')).toBe(false);
});

test('rejects password without digit', () => {
  expect(isValidPassword('Helloooo')).toBe(false);
});

test('rejects password missing both uppercase and digit', () => {
  expect(isValidPassword('helloooo')).toBe(false);
});

Jetzt wird die Grenze bei 8 explizit getestet. Der <=-Mutant schlägt fehl, weil 'Hello1!@' (8 Zeichen) akzeptiert werden muss. Der Lösch-Mutant schlägt fehl, weil 'helloooo' ohne die Längenprüfung durchschlüpfen würde.

Wie Mutation Testing unter der Haube wirklich funktioniert

Mutation Testing ist rechenintensiv, weil es deine komplette Test-Suite einmal pro Mutant ausführt.

Wenn deine Codebase 10.000 Zeilen hat und dein Mutation-Tool 3.000 Mutanten erzeugt, sind das 3.000 Test-Suite-Läufe. Frühe akademische Implementierungen waren aus diesem Grund auf echten Codebases praktisch unbrauchbar. Moderne Tools sind schlauer geworden.

Stryker, das am weitesten verbreitete Mutation-Testing-Framework für JavaScript und TypeScript, nutzt mehrere Optimierungen:

  1. Mutant Scoping: Stryker führt nur die Teilmenge der Tests aus, die die mutierte Zeile überhaupt erreichen könnten – basierend auf Coverage-Daten aus einem initialen Dry Run.

  2. Parallele Ausführung: Mutanten werden über Worker-Prozesse hinweg ausgewertet.

  3. Inkrementeller Modus: Stryker cached Ergebnisse und wertet nur Mutanten für Code neu aus, der sich seit dem letzten Lauf geändert hat.

  4. Checker: Für kompilierte Sprachen kann Stryker Mutanten auf AST-Ebene verifizieren, ohne das gesamte Projekt neu zu kompilieren.

Selbst mit diesen Optimierungen kann ein vollständiger Mutation-Test-Lauf auf einer großen Codebase immer noch 10–30 Minuten dauern. Deshalb führen die meisten Teams Mutation Testing in der CI bei Pull Requests oder nächtlichen Builds durch – nicht bei jedem Speichern.

Die Trade-Offs, über die niemand spricht

Mutation Testing ist nicht umsonst, und es ist nicht immer das richtige Werkzeug.

Das Equivalent-Mutant-Problem ist die größte theoretische Einschränkung. Manche Mutationen ändern das beobachtbare Verhalten nicht. Betrachte:

const timeout = 1000 * 60;

Eine Mutation, die das in 1000 * 61 ändert, ist semantisch verschieden. Aber eine Mutation, die es in 60 * 1000 ändert, ist äquivalent. Kein Test kann sie töten, weil der Wert identisch ist. Äquivalente Mutanten von echten Überlebenden zu unterscheiden, ist im Allgemeinen unentscheidbar. Moderne Tools nutzen Heuristiken, um offensichtliche Fälle zu überspringen, aber du wirst trotzdem welche sehen.

Performance ist real. Auf einem mittelgroßen TypeScript-Projekt könnte Stryker 2.000 Mutanten erzeugen und 15 Minuten brauchen, um sie auszuwerten. Das sind 15 Minuten CI-Zeit bei jedem Lauf, wenn du es für Pull Requests aktivierst. Teams starten typischerweise mit einem Threshold (z. B. Build fehlschlagen lassen, wenn der Mutation-Score unter 60 % fällt) und führen die vollständige Analyse nächtlich durch.

Falsche Sicherheit schlägt in beide Richtungen aus. Ein Mutation-Score von 100 % bedeutet nicht, dass dein Code keine Bugs hat. Es bedeutet, dass kein Bug, der den Mutation-Operatoren des Tools entspricht, durchgeschlüpft wäre. Mutation Testing kann keine Bugs erfinden, die es nicht zu erzeugen weiß. Es wird keine logischen Fehler in deinen Anforderungen finden, keine Race Conditions, die es nicht simulieren kann, und keine Integrationsfehler über Service-Grenzen hinweg.

Wie du Mutation Testing wirklich einsetzt

Wenn du JavaScript oder TypeScript schreibst, ist Stryker der richtige Einstieg.

Installiere es:

npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner

Erstelle stryker.config.mjs:

// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  packageManager: 'npm',
  reporters: ['html', 'clear-text', 'progress'],
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  mutate: ['src/**/*.js'],
  threshold: {
    break: 60,
  },
};

export default config;

Führe es aus:

npx stryker run

Fang damit an, dir den HTML-Report anzusehen – nicht den Score. Der Report zeigt jeden überlebenden Mutanten inline in deinem Quellcode an. Geh die ersten zehn Überlebenden durch. Frag dich bei jedem: Würde ein echter Bug an dieser Stelle ein Produktionsproblem verursachen? Wenn ja, schreibe einen Test, der ihn erwischt. Wenn nein, überlege, ob der Code überentwickelt ist.

Jag nicht die 100 %. Auf einer ausgereiften Codebase sind 70–80 % ein starker Score. Unter 50 % hast du wahrscheinlich Tests, die Code ausführen, ohne etwas Bedeutendes zu asserten. Über 90 % erzielst du wahrscheinlich abnehmende Erträge und eine wachsende Steuer durch äquivalente Mutanten.

Was du mit deinen 40 % machst

Ein Mutation-Score von 40 % ist ein Geschenk. Er sagt dir genau, wo deine Tests nur Dekoration sind.

Nimm die drei Dateien mit den meisten überlebenden Mutanten. Geh jeden Überlebenden durch und frag dich, welche Assertion fehlt. Oft ist der Fix einfach: Du hast eine Funktion im Test aufgerufen, aber nie den Rückgabewert geprüft. Oder du hast Daten durch einen Parser geschickt, aber nie die geparste Ausgabe verifiziert. Oder du hast den Happy Path dreimal mit verschiedenen Inputs getestet, aber nie den Error-Branch.

Die Mutanten sind kein Rauschen. Sie sind eine Rangliste der wahrscheinlichsten Orte, an denen ein ungetesteter Bug lauert. Fang oben an.


FAQ

Was ist der Unterschied zwischen Code Coverage und Mutation Testing? Code Coverage misst, welche Zeilen ausgeführt wurden. Mutation Testing misst, ob deine Tests fehlschlagen würden, wenn diese Zeilen einen Bug enthalten würden. 100 % Coverage bei einem Mutation-Score von 40 % bedeuten, dass du jede Zeile ausgeführt hast, aber deine Tests es nicht bemerkt hätten, wenn die meisten von ihnen falsch wären.

Kann Mutation Testing Bugs in meinem bestehenden Code finden? Nein. Mutation Testing evaluiert deine Tests, nicht deinen Quellcode. Es sagt dir, wo deine Tests unzureichend sind. Es sagt dir nicht, ob dein Code korrekt ist – nur, ob deine Tests bestimmte Fehlerklassen erwischen würden.

Welche Sprachen haben gute Mutation-Testing-Tools? JavaScript/TypeScript (Stryker), Java (PIT), C# (Stryker.NET), Python (mutmut) und Rust (cargo-mutants) haben allesamt ausgereifte Tools. Das Ökosystem variiert in Performance und unterstützten Mutation-Operatoren.

Sollte Mutation Testing Code Coverage ersetzen? Nein. Coverage ist billig und schnell. Nutze es für schnelles Feedback während der Entwicklung. Nutze Mutation Testing als periodisches Quality Gate, um die Blind Spots zu finden, die Coverage nicht sehen kann.