Zum Inhalt springen

cat posts/mutation-testing.md

Mutation Testing: Sind Deine Tests gut?

Coverage zeigt, welcher Code ausgeführt wurde – nicht, ob Tests kleine Verhaltensänderungen bemerken. Mutation Testing baut kontrolliert Fehler ein und prüft, ob die Tests darauf reagieren.

Agent im Loop (Teil 6) nannte Coverage eine Landkarte, kein Ziel. Sie zeigt Dir, welche Stellen beim Testen ausgeführt wurden. Ob Deine Tests dort tatsächlich merken würden, wenn sich das Verhalten ändert, ist eine andere Frage.

Genau da setzt Mutation Testing an: Ein Werkzeug verändert den produktiven Code absichtlich und prüft, ob mindestens ein Test darauf reagiert.

Statt also nur zu fragen

Ist diese Zeile getestet?

fragst Du:

Würde meine Testsuite merken, wenn diese Zeile falsch wäre?

Das kommt dem eigentlichen Zweck von Tests deutlich näher.

Bugs absichtlich einbauen

Mutation Testing erzeugt viele leicht veränderte Versionen Deines Codes, sogenannte Mutanten.

Aus

if alter >= 18:

könnte beispielsweise werden:

if alter > 18:

oder aus:

return a + b

wird:

return a - b

Danach läuft die Testsuite gegen diese veränderte Version.

Scheitert mindestens ein Test, wurde der Mutant getötet (killed). Die Tests haben also bemerkt, dass sich das Verhalten geändert hat.

Bleiben alle Tests grün, überlebt der Mutant (survived). Dann lohnt ein genauerer Blick: Vielleicht fehlt ein Test oder eine vorhandene Assertion ist zu schwach.

100 % Coverage und trotzdem blind

Nehmen wir diese Funktion:

def ist_volljaehrig(alter):
    return alter >= 18

Dazu zwei zunächst völlig vernünftig aussehende Tests:

def test_volljaehrig():
    assert ist_volljaehrig(20)


def test_minderjaehrig():
    assert not ist_volljaehrig(10)

Beide Tests laufen durch die einzige ausführbare Zeile der Funktion. Für diese Funktion erreichst Du damit 100 % Line-Coverage.

Jetzt erzeugen wir gedanklich einen Mutanten:

def ist_volljaehrig(alter):
    return alter > 18

Die Bedeutung hat sich geändert. Eine Person mit exakt 18 Jahren gilt plötzlich als minderjährig.

Unsere Tests merken davon nichts:

assert ist_volljaehrig(20)
assert not ist_volljaehrig(10)

Beide bleiben grün.

Der Mutant überlebt.

Erst ein Test des tatsächlichen Grenzwerts macht den Unterschied sichtbar:

def test_grenze_zur_volljaehrigkeit():
    assert not ist_volljaehrig(17)
    assert ist_volljaehrig(18)

Mit dem Originalcode ist der Test grün. Mit alter > 18 wird er rot.

Der Mutant ist getötet.

Mutation Testing hat hier keinen exotischen Bug entdeckt. Es hat gezeigt, dass unsere Tests die eigentliche Bedeutung des Vergleichsoperators nicht vollständig abgesichert haben.

Coverage und Mutation Testing messen unterschiedliche Dinge

Coverage beantwortet Fragen wie:

  • Wurde diese Zeile ausgeführt?
  • Wurde dieser Branch genommen?
  • Welche Teile des Codes wurden überhaupt nicht erreicht?

Das ist wertvoll. Code ohne Coverage verdient meistens zuerst Aufmerksamkeit.

Mutation Testing stellt eine andere Frage:

Reagiert die Testsuite empfindlich auf eine kleine Änderung dieses Codes?

Eine Zeile kann vollständig von Tests durchlaufen werden, ohne dass ihr Ergebnis irgendwo wirksam überprüft wird.

Zum Beispiel:

def preis_mit_rabatt(preis):
    rabatt = preis * 0.1
    return preis - rabatt

Dieser Test führt jede Zeile aus:

def test_preis():
    preis_mit_rabatt(100)

Die Line-Coverage ist trotzdem wesentlich beeindruckender als der Test.

Würde ein Mutationswerkzeug

preis - rabatt

zu

preis + rabatt

ändern, bliebe der Test grün.

Coverage und Mutation Testing sind deshalb keine Konkurrenten. Coverage hilft, ungetesteten Code zu finden. Mutation Testing untersucht, wie aussagekräftig die Tests für den bereits erreichten Code sind.

Mutation Testing mit mutmut

Für Python ist mutmut ein unkomplizierter Einstieg.

Installation:

pip install mutmut

Für ein übliches Projekt mit pytest reicht häufig zunächst:

mutmut run

mutmut sucht standardmäßig unter anderem nach Tests in tests oder test und versucht zu bestimmen, welcher Code mutiert werden soll.

Nach dem Lauf kannst Du die Ergebnisse interaktiv ansehen:

mutmut browse

Dort siehst Du, welche Mutanten getötet wurden und welche überlebt haben. Einzelne Mutanten kannst Du näher untersuchen und bei Bedarf sogar auf den Arbeitsbaum anwenden, um exakt zu sehen, was verändert wurde.

Dafür gibt es auch:

mutmut apply <mutant>

Das solltest Du natürlich nur in einem sauberen Git-Working-Tree tun. Der Befehl verändert tatsächlich Deinen Code.

Bei größeren Projekten lohnt sich eine explizite Konfiguration. In pyproject.toml kannst Du beispielsweise festlegen, welcher Quellcode und welche Tests berücksichtigt werden:

[tool.mutmut]
source_paths = ["src/"]
pytest_add_cli_args_test_selection = ["tests/"]

Aktuelle mutmut-Versionen benötigen ein System mit fork-Support. Unter Windows bedeutet das in der Praxis: innerhalb von WSL ausführen.

Was ein überlebender Mutant wirklich bedeutet

Die einfache Erklärung lautet oft:

Mutant überlebt = Test fehlt.

Als erste Heuristik ist das nützlich, aber zu pauschal.

Ein überlebender Mutant kann verschiedene Ursachen haben.

Der Test fehlt tatsächlich

Das ist der interessanteste Fall.

Original:

def versandkosten(bestellwert):
    if bestellwert >= 50:
        return 0
    return 4.99

Mutant:

if bestellwert > 50:

Wenn kein Test 50 prüft, hast Du sehr wahrscheinlich einen echten blinden Fleck gefunden.

Der Test existiert, prüft aber zu wenig

Vielleicht wird der betreffende Code ausgeführt, aber die Assertion beobachtet das Ergebnis nicht.

Dann fehlt nicht unbedingt ein weiterer Testfall. Häufig reicht es, den vorhandenen Test präziser zu machen.

Der Mutant ist äquivalent

Manche Änderungen sehen im Sourcecode anders aus, ändern das beobachtbare Verhalten aber nicht.

Angenommen, eine Bedingung kann aufgrund vorheriger Logik nur Werte größer als zehn sehen:

if wert >= 1:

Ein Mutant

if wert > 1:

könnte für alle tatsächlich erreichbaren Zustände exakt dasselbe Verhalten haben.

Ein solcher äquivalenter Mutant kann prinzipiell von keinem Test getötet werden, weil es keinen beobachtbaren Unterschied gibt.

Das ist eines der grundlegenden Probleme von Mutation Testing: Ob zwei beliebige Programme für alle möglichen Eingaben dasselbe Verhalten haben, lässt sich nicht allgemein automatisch entscheiden.

Der Code ist tot oder praktisch unerreichbar

Wenn ein Mutant in Code steckt, der niemals ausgeführt werden kann oder nicht mehr gebraucht wird, hilft kein zusätzlicher Test viel.

Dann ist die bessere Reaktion womöglich:

Code löschen.

Auch das ist ein nützliches Ergebnis.

Das Verhalten interessiert Dich bewusst nicht

Nicht jede syntaktische Änderung ist fachlich relevant. Ein Mutant in reinem Logging oder anderer nicht beobachteter Infrastruktur kann für Deine Tests absichtlich uninteressant sein.

Mutation Testing liefert also keine automatische To-do-Liste. Es liefert Stellen, die Du untersuchen solltest.

Der Mutation Score

Mutation-Testing-Werkzeuge fassen ihre Ergebnisse häufig zu einem Mutation Score zusammen.

Vereinfacht:

getötete Mutanten
─────────────────
relevante Mutanten

In der klassischen Definition werden äquivalente Mutanten aus dem Nenner entfernt:

Mutation Score =
    getötete Mutanten
    ─────────────────────────────
    alle Mutanten - äquivalente Mutanten

Das klingt sauberer, als es praktisch ist: Äquivalente Mutanten lassen sich nicht zuverlässig automatisch erkennen. Werkzeuge unterscheiden sich deshalb darin, welche Status sie zählen und wie sie den Score berechnen.

Der Score ist ohnehin weniger interessant als die einzelnen Überlebenden.

93 % sagt Dir:

Irgendwas ist ziemlich gut.

Ein konkreter Survivor wie

- if alter >= 18:
+ if alter > 18:

sagt Dir dagegen ziemlich genau, welchen Fall Deine Tests möglicherweise übersehen.

Mutation Testing ist deshalb am nützlichsten als Diagnosewerkzeug, nicht als Wettbewerb um möglichst nahe an 100 % zu kommen.

Nicht jeder Mutant ist gleich wertvoll

Mutation Testing arbeitet mit sogenannten Mutation Operators. Sie verändern zum Beispiel:

>=  →  >
==  →  !=
+   →  -
True → False

Solche kleinen Änderungen sollen typische Programmierfehler simulieren.

Dabei entstehen zwangsläufig Mutanten, die wesentlich interessanter sind als andere.

Ein überlebender Boundary-Mutant wie

- alter >= 18
+ alter > 18

ist oft ein ziemlich klarer Hinweis auf einen fehlenden Grenzwerttest.

Ein obskurer Mutant tief in defensivem Code kann dagegen wenig zusätzlichen Nutzen bringen.

Die Anzahl getöteter Mutanten allein sagt deshalb nicht alles über die Qualität einer Testsuite aus.

Warum Mutation Testing langsam ist

Coverage braucht im Wesentlichen einen Lauf der Testsuite mit zusätzlicher Instrumentierung.

Mutation Testing kann dagegen hunderte oder tausende Varianten des Programms erzeugen. Jeder relevante Mutant muss gegen Tests geprüft werden.

Eine Testsuite von zehn Sekunden wird dadurch nicht plötzlich zehn Sekunden plus ein bisschen Overhead dauern.

Moderne Werkzeuge versuchen das zu begrenzen. mutmut merkt sich beispielsweise bereits erledigte Arbeit, kann Mutanten parallel prüfen und versucht, nur Tests auszuführen, die für einen bestimmten Mutanten relevant sind.

Trotzdem bleibt Mutation Testing wesentlich teurer als ein normaler Testlauf.

Für ein großes Projekt ist deshalb

mutmut run

über den kompletten Code nach jedem kleinen Commit selten die sinnvollste Strategie.

Interessanter sind gezielte Bereiche:

mutmut run "mein_modul*"

oder sogar einzelne Funktionen:

mutmut run "mein_modul.meine_funktion*"

So kannst Du genau den Code untersuchen, an dem Du gerade arbeitest.

Erst muss die normale Testsuite grün sein

Mutation Testing ergibt nur Sinn, wenn der unveränderte Code die Tests bereits besteht.

Ist der Ausgangszustand rot, kannst Du später nicht unterscheiden:

Hat der Mutant diesen Test kaputtgemacht?

von:

War der Test sowieso schon kaputt?

Das klingt selbstverständlich, wird bei automatisierten Pipelines aber schnell relevant.

Cosmic Ray macht diesen Schritt sogar explizit und bezeichnet ihn als Baseline: Zuerst wird geprüft, ob die Tests gegen den unveränderten Code erfolgreich laufen. Danach beginnt die eigentliche Mutation Session.

Cosmic Ray als Alternative

Cosmic Ray verfolgt einen etwas anderen Ansatz als mutmut. Es arbeitet mit einer Konfiguration und einer persistenten Session.

Der typische Ablauf sieht ungefähr so aus:

cosmic-ray new-config mutation.toml
cosmic-ray init mutation.toml mutation.sqlite
cosmic-ray baseline mutation.toml
cosmic-ray exec mutation.toml mutation.sqlite
cr-report mutation.sqlite

Die Konfiguration beschreibt unter anderem den zu mutierenden Code, das Testkommando und Timeouts.

Das ist mehr Setup als bei einem einfachen

mutmut run

bietet dafür aber explizite Sessions und verschiedene Möglichkeiten zur Verteilung der Mutation Jobs.

Für einen ersten Versuch mit einem pytest-Projekt ist mutmut meist der direktere Einstieg. Cosmic Ray wird interessant, wenn Du den Ablauf stärker konfigurieren oder in eine eigene Infrastruktur einpassen möchtest.

Besonders nützlich an Grenzen

Mutation Testing findet häufig genau die Stellen, an denen Beispieltests schwach sind:

x > 10
x >= 10
x == 0
x != 0
return True
return False
a + b
a - b

Gerade Grenzbedingungen fallen dabei schnell auf.

Das ergänzt sich gut mit Property-Based Testing. Hypothesis sucht Eingaben, die eine von Dir formulierte Eigenschaft verletzen. Mutation Testing dreht die Richtung gewissermaßen um: Die Eingaben bleiben Deine Tests, stattdessen verändert sich die Implementierung.

Beide Verfahren können Schwächen aufdecken, die eine kleine Sammlung handgeschriebener Happy-Path-Tests übersieht.

Und was hat das mit Coding-Agenten zu tun?

Beim Arbeiten mit einem Coding-Agenten ist ein grüner Testlauf allein ein schwaches Signal.

Ein Agent kann vorhandene Tests erfüllen und trotzdem eine zu enge Implementierung bauen. Besonders problematisch wird das, wenn er gleichzeitig Code und Tests erzeugt: Dann können sich beide denselben falschen Annahmen anpassen.

Coverage hilft dagegen nur begrenzt. Ein Agent kann problemlos Tests erzeugen, die jede Zeile ausführen, ohne das relevante Verhalten wirklich festzunageln.

Mutation Testing stellt eine unbequemere Frage:

Wenn ich die Implementierung leicht falsch mache, merken Deine Tests das?

Überlebt beispielsweise

- if result >= minimum:
+ if result > minimum:

obwohl genau diese Grenze Teil der Spec ist, hat die Testsuite ein echtes Problem – egal ob sie 100 % Coverage meldet.

Das ist der Grund, warum Mutation Testing in Agent im Loop (Teil 6) zur Härtung der Schleife passt: Nicht der Agent soll behaupten, dass seine Tests gut sind. Die Tests müssen zeigen, dass sie falschen Code erkennen.

Mutation Testing nicht zum Selbstzweck machen

Es ist verlockend, nach dem ersten Lauf jeden Survivor beseitigen zu wollen.

Dann entstehen schnell Tests wie:

def test_interne_implementation():
    ...

die ausschließlich deshalb existieren, um einen bestimmten Mutanten zu töten. Am Ende ist der Mutation Score besser, die Testsuite aber stärker an Implementierungsdetails gekoppelt.

Bei jedem Survivor lohnt deshalb dieselbe Frage:

Beschreibt der Test, den ich dafür schreiben würde, ein Verhalten, auf das ich mich wirklich verlassen möchte?

Wenn ja, fehlt wahrscheinlich ein sinnvoller Test.

Wenn nein, ist es möglicherweise der Mutant, der uninteressant ist.

Mutation Testing soll Deine Tests hinterfragen. Es sollte sie nicht dazu zwingen, jede zufällige interne Entscheidung der Implementierung zu konservieren.

Stolperfallen

  • Survivor bedeutet nicht automatisch „Test schreiben“. Prüfe zuerst, ob der Mutant fachlich relevant und überhaupt killbar ist.
  • 100 % Mutation Score ist kein Beweis für korrekten Code. Getestet werden nur die Mutationen, die das Werkzeug erzeugt. Ein echter Bug kann völlig anders aussehen.
  • Äquivalente Mutanten erzeugen Rauschen. Manche Veränderungen haben schlicht keinen beobachtbaren Effekt und können von keinem Test getötet werden.
  • Mutation Testing ist teuer. Nutze es gezielt für wichtigen oder frisch geänderten Code, statt zwangsläufig das komplette Repository bei jedem Commit zu mutieren.
  • Flaky Tests werden noch lästiger. Wenn ein Test schon ohne Mutation sporadisch scheitert, lassen sich Ergebnisse kaum sinnvoll interpretieren.
  • Der Score ist nur ein Signal. Ein konkreter überlebender Boundary-Mutant kann wertvoller sein als ein Sprung von 94 auf 95 Prozent.
  • Teste Verhalten, nicht Mutanten. Schreibe keinen seltsamen Test nur, damit ein Werkzeug grün wird. Der zusätzliche Test sollte eine echte Anforderung absichern.

Coverage beantwortet die Frage, wo Deine Tests waren. Mutation Testing fragt, ob sie dort aufgepasst haben.

Genau deshalb ist ein überlebender Mutant oft interessanter als eine weitere grüne Coverage-Zeile: Er zeigt eine konkrete Änderung, die Deine Tests gerade nicht bemerken. Dann kannst Du entscheiden, ob das ein blinder Fleck ist – oder völlig in Ordnung.

Weiterlesen

Sources: mutmut – Dokumentation, Cosmic Ray – Dokumentation, PIT: Mutation Testing – Basic Concepts.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!