Zurück zum Blog

Ein Test-Double, das nicht falsch antworten kann, prüft nichts

Ein Test für Mandantentrennung blieb grün, obwohl ich die schützende Zeile gelöscht hatte: Sein Fake konnte die falsche Antwort nie liefern. Zwei Fragen an jedes Double, eine Mutation je Mechanismus, und warum ein gemocktes sleep Tests hängen statt rot werden lässt.

4 Min. Lesezeitvon Daniel AlischEngineering, Testing, KI-Agenten

Ich habe einen Test gesehen, der grün blieb, nachdem ich die Zeile entfernt hatte, die er schützen sollte. Kein Flackern, kein Zufall: Der Test war von Anfang an leer. Er stand in einer Suite für eine Agenten-Plattform, und er sollte sicherstellen, dass ein Agent sich bei der Frage nach seinen Kollegen nicht selbst mit aufzählt. Die Produktion erledigte das mit einer einzigen Zeile, die den Auftraggeber aus der Ergebnismenge streicht. Ich habe die Zeile gelöscht, die Suite laufen lassen — alles grün.

Wie ein Test die Grenzen seines Fakes prüft

Die Ursache lag nicht im Test selbst, sondern in dem Ersatzobjekt, das statt der Datenbank antwortete. Die Namensliste dieses Fakes enthielt den Auftraggeber gar nicht. Die Abfrage konnte ihn also nie zurückgeben, ganz gleich, ob die Produktion ihn herausfiltert oder nicht. Der Test behauptete Mandantentrennung. Geprüft hat er nur, dass ein Name, der nicht in der Liste steht, auch nicht im Ergebnis auftaucht.

Seitdem stelle ich mir bei jedem neuen Double zwei Fragen, bevor ich den ersten Assert schreibe:

  • Welche Antwort wäre hier falsch?
  • Kann mein Double diese falsche Antwort überhaupt liefern?

Lautet die zweite Antwort nein, ist der Test wertlos — und im grünen Lauf ist er von einem echten Test nicht zu unterscheiden. Das ist das Unangenehme an dieser Fehlerklasse: Sie fällt nie auf, solange alles funktioniert, und genau dann, wenn etwas kaputtgeht, schweigt sie.

Es gibt eine zweite Ausprägung derselben Signatur. Ein Double gibt eine feste Wunschantwort zurück, statt sie aus den Parametern abzuleiten, mit denen es tatsächlich aufgerufen wurde. Dann ist es egal, ob der Code den richtigen Filter, die richtige Sortierung oder überhaupt eine Bedingung übergibt — das Ergebnis steht vorher fest.

Die Gegenprobe: je Mechanismus eine Mutation

Gefunden habe ich den Fall nicht beim Lesen, sondern mit einer Gegenprobe. Für jeden Mechanismus, den ein Test absichern soll, baue ich genau eine Mutation in den Produktionscode ein: Filter entfernen, Bedingung umdrehen, Aufruf auskommentieren. Danach muss der zuständige Test rot werden. Bleibt er grün, hat er nie geprüft, was sein Name verspricht.

import subprocess

MUTATIONEN = [
    ("ids.discard(created_by)", "pass"),
    ("if not item.archived:", "if True:"),
]

for alt, neu in MUTATIONEN:
    quelle = open("app/team.py", encoding="utf-8").read()
    open("app/team.py", "w", encoding="utf-8").write(quelle.replace(alt, neu, 1))
    try:
        r = subprocess.run(["pytest", "-q", "tests/test_team.py"],
                           capture_output=True, timeout=120)
        print(alt, "->", "ROT" if r.returncode else "GRÜN = blinder Test")
    except subprocess.TimeoutExpired:
        print(alt, "->", "HÄNGT")
    finally:
        open("app/team.py", "w", encoding="utf-8").write(quelle)

Wichtig ist, dass jede Mutation einzeln läuft. Wer mehrere Änderungen auf einmal einbaut, sieht nur, dass irgendein Test rot wird, und erfährt nicht, welcher Mechanismus tatsächlich bewacht ist.

Wenn der Test hängt, statt rot zu werden

Die dritte Spalte im Skript oben hat ihren Grund. Beim Gegenlesen einer anderen Testdatei ließen zwei Mutationen die Tests nicht rot werden, sondern hängen. Der Test fuhr eine Produktionsschleife und ersetzte die Wartefunktion asyncio.sleep durch ein AsyncMock. Das kehrt sofort zurück, gibt aber nie die Kontrolle an die Ereignisschleife ab. Machte die Mutation aus der Schleife eine Endlosschleife, konnte nicht einmal ein umschließendes asyncio.wait_for eingreifen, weil es selbst nie wieder an die Reihe kam.

Ein hängender Test sieht in der Auswertung wie ein Fehlschlag aus, belegt aber nichts — und in der CI frisst er das Zeitbudget. Die Abhilfe ist klein: die echte Wartefunktion vor dem Patch sichern und einen Ersatz verwenden, der await echtes_sleep(0) ausführt. Damit bleibt der Test schnell, die Ereignisschleife kommt aber bei jedem Durchlauf zum Zug, und ein Zeitlimit kann greifen. Zusätzlich gehört jeder Aufruf einer potenziell endlosen Koroutine in wait_for, auch dort, wo er angeblich sofort zurückkehrt.

Text im Quelltext ist keine Wirkung

Die gleiche Blindstelle hat eine dritte Form, und sie ist verbreiteter, als man glaubt: Tests, die den Quelltext einer Datei einlesen und prüfen, ob eine bestimmte Zeile darin vorkommt. Ein Gegenleser zeigte mir, dass eine solche Zusicherung genauso besteht, wenn die Zeile auskommentiert hinter einem pass steht. Auch Wortsuchen in Oberflächendateien bestanden bei vertauschten Beschriftungen.

Das Problem ist nicht, wie groß der durchsuchte Ausschnitt ist. Das Problem ist, dass Text im Quelltext geprüft wird statt seiner Wirkung. Wo es einen Testläufer gibt, baue ich solche Stellen in Verhaltenstests um: die Schleife mit einem Double fahren und prüfen, dass der Handler tatsächlich mit den erwarteten Argumenten aufgerufen wurde. Wo es keinen gibt, prüfe ich wenigstens ganze Anweisungen, nachdem Kommentare entfernt wurden, und schließe die vertauschte Variante ausdrücklich aus.

Fazit

Ein grüner Test sagt nur dann etwas, wenn er auch rot werden könnte. Das klingt banal, ist aber im Alltag kaum zu sehen, weil ein leerer Test genauso aussieht wie ein voller. Die beiden Fragen an jedes Double und eine Mutation je Mechanismus kosten wenige Minuten. In meinem Fall haben sie eine Sicherheitszusicherung entlarvt, die niemand bewacht hat, obwohl die Suite die ganze Zeit grün war.

Weiterlesen