Zum Inhalt springen

Suchen...

Warum vollständige Coverage keine Fehlerfreiheit garantiert

100% Branch Coverage erreicht – und trotzdem noch Bugs gefunden. Was das über Code Coverage als Metrik aussagt und warum Assertions entscheiden.

7 Min. Lesezeit
Cover zum Expertengespräch über 'Warum vollständige Coverage keine Fehlerfreiheit garantiert' mit Roger Butenuth und Richard Seidl.

Branch Coverage ist eine Form der Code-Coverage, bei der jeder mögliche Zweig einer Bedingung getestet werden muss. Ein einzelnes IF mit einer Und-Bedingung erfordert so mindestens drei Testfälle. Hundert Prozent Branch Coverage verhindert viele Fehler, garantiert aber keine Fehlerfreiheit: Schleifen mit mehreren Durchläufen können weiterhin Bugs verbergen.

Das Wichtigste in Kürze

  • 100% Branch Coverage schützt nicht vor allen Fehlern: Ein Off-by-One-Fehler in einer kopierten Methode überlebte die vollständige Testabdeckung, weil nur null und ein Schleifendurchlauf getestet wurden, nicht mehrere.
  • Eine Sicherheitslücke in der Authentifizierung, ein Variablenname statt zwei verglichen, fand Roger Butenuth nur, weil Branch Coverage alle drei Zweige einer zusammengesetzten Bedingung erzwingt, nicht bloß die Zeile.
  • Testbarkeit entsteht durch Codeänderungen: Dependency Injection und klare Interfaces ermöglichten es, I/O-Fehler und Standard-Output kontrolliert im Test auszulösen, ohne Hardware-Grenzen zu treffen.
  • Konsequentes Don’t Repeat Yourself, erzwungen durch den Testaufwand für kopierte Zweizeiler, verbessert Lesbarkeit und Wartbarkeit, weil Änderungen nur noch an einer Stelle nötig sind.
  • Eine Coverage-Metrik als Vorgabe funktioniert nicht, weil Entwickler jede messbare Schwelle erfüllen können, ohne sinnvolle Assertions zu schreiben, was die Aussagekraft der Zahl auf null senkt.

100 Prozent Branch Coverage: ein Selbstversuch mit klarer Lehre

Volle Branch Coverage ist erreichbar, deckt aber nicht alle Fehler auf. Roger Butenuth hat für einen selbst gebauten Interpreter jede einzelne Verzweigung im Code mit Tests abgedeckt und trotzdem noch Bugs gefunden, nachdem die Metrik längst auf 100 Prozent stand.

Branch Coverage geht dabei weiter als die üblichen Kennzahlen. Sie misst nicht Methoden oder Zeilen, sondern jeden möglichen Zweig im Kontrollfluss. Ein if mit einer Und-Bedingung braucht deshalb nicht einen Testfall, sondern drei, damit jede Kombination der Verzweigung durchlaufen wird.

Genau diese Härte macht den Unterschied zur gängigen Praxis. In vielen Teams einigt man sich auf 70 oder 80 Prozent Coverage, meist auf Line- oder Statement-Ebene. Branch Coverage in dieser Konsequenz zieht kaum jemand durch, weil der Aufwand hoch ist.

Das Projekt: ein Lisp-artiger Interpreter in Java

Der Ausgangspunkt war kein Testprojekt, sondern ein Interpreter für eine Lisp-ähnliche Sprache, gebaut in Java. Lisp steht für List Processing, und der Kern der Sache sind Listen: immutable, also persistent. Ruft man eine Operation auf, verändert man die bestehende Liste nicht, sondern bekommt eine neue Liste zurück, die aussieht wie die alte plus Änderung.

Naiv würde man dafür jedes Mal die komplette Liste kopieren. Das will niemand. Gefragt ist eine Implementierung, die möglichst viel wiederverwendet. Die vorhandenen Optionen taugten für diesen Zweck nur bedingt.

  • Die ArrayList von Java ist nicht immutable.
  • Scalas Single-Linked List ist ineffizient beim Zugriff auf das n-te Element, weil man n-mal durchlaufen muss.
  • Scalas Indexbaum war effizient, verbrauchte aber schon für eine Liste mit einem Element über 200 Byte, zu viel für viele kleine Listen.

Also entstand eine eigene Struktur: ein zweidimensionales Array mit exponentiell wachsenden Sub-Arrays, die meisten Operationen mit logarithmischem Aufwand. Der Preis dafür war eine komplizierte Implementierung mit hohem Risiko für Plus-Minus-1-Fehler. Genau deshalb wurde diese Liste gründlich getestet und lag schnell bei 100 Prozent Coverage. Aus diesem Punkt wuchs der Ehrgeiz, die Abdeckung auf den ganzen Interpreter auszudehnen.

Reine Funktionen sind leicht, Seiteneffekte sind die eigentliche Arbeit

Der einfache Teil sind Funktionen ohne Nebeneffekte. Arithmetik und andere reine Funktionen im Sinne der funktionalen Programmierung verhalten sich wie abstrakte Datentypen. Sie haben keine Abhängigkeiten nach außen, und ihre Tests bleiben überschaubar.

Schwierig wird es bei allem, was die Außenwelt berührt. I/O-Operationen wie Datei lesen und schreiben brauchen vorbereitete Testdaten. Für den eingebauten HTTP-Client und HTTP-Server muss jeweils das Gegenstück existieren. Hier half, dass die Sprache Client und Server ohnehin beide mitbrachte, beide auf Standard-Java-Bibliotheken aufgesetzt.

Der teuerste Anspruch ist die Vollständigkeit. Wer 100 Prozent will, muss auch den Exception-Handler einer Dateioperation testen. Und dabei will niemand für einen Test die Festplatte vollschreiben.

Testbarkeit heißt oft: den Code ändern, damit er testbar wird

Die Lösung für diese schwer erreichbaren Zweige war Dependency Injection über Interfaces. Der Scanner der Sprache bekommt nur einen Java Reader gereicht. Für einen Test lässt sich ein Reader einsetzen, der an definierter Stelle eine IOException wirft. So wird der Exception-Pfad gezielt ansteuerbar, ohne echte Fehlerfälle zu provozieren.

Ähnlich beim Standard-Output. Der Interpreter läuft normal mit System.out, doch der darin steckende PrintStream lässt sich austauschen. In einem JUnit-Test fängt ein eigener PrintStream ab, was der Interpreter ausgibt, und macht die Ausgabe prüfbar.

An mehreren Stellen wurde dafür der Produktivcode selbst geändert. Das ist die unbequeme Wahrheit hinter hoher Testbarkeit: Manchmal ist es besser, den Code so umzubauen, dass man ihn testen kann, als auf einer schwer testbaren Struktur zu bestehen.

In einem echten Projekt ist das schwerer durchzusetzen. Hier gab es keinen externen Product Owner, der Autor war sein eigener. In der Praxis wird jemand einwenden, warum man Code allein wegen der Testbarkeit ändern soll. Die Antwort bleibt dieselbe: Testbarer Code ist häufig der bessere Code.

Testbarer Code wird oft auch besser lesbar und wartbar

Der Umbau brachte mehr als nur grüne Metriken. An einer Stelle wurde in die Listenfunktionen eine Methode eingebaut, die die innere Struktur der Liste ausgibt. Im Produktivbetrieb braucht das niemand, im Test prüft sie, ob die Liste intern die erwartete Struktur hat.

Dependency Injection macht den Code flexibler, auch ohne ein Framework wie Spring Boot dahinter. Der zweite Hebel war konsequentes Don’t Repeat Yourself. Kleine Prüfungen wie “wenn Liste leer, wirf Exception” werden gern als Zweizeiler in viele Funktionen hineinkopiert. Jede dieser Kopien verlangt beim Testen wieder eigene Testfälle.

Zieht man solche Mini-Prüfungen in eigene Funktionen aus, sinkt die Zahl der Testfälle, und die Lesbarkeit steigt an vielen Stellen sogar. Änderungen finden dann nur noch an einer Stelle statt. Die Kehrseite: Wer an dieser einen Stelle etwas ändert, kann auch viele Aufrufer auf einmal zerlegen. Eine hohe Testabdeckung fängt genau das ab, weil größere Umbauten schnell wieder kontrollierbar werden.

Das Verhältnis von Produktiv- zu Testcode lag am Ende bei ungefähr eins zu eins.

100 Prozent fangen das Triviale, nicht das Übersehene

Volle Branch Coverage findet Fehler dort, wo man sie nicht erwartet. Der übelste Fund war eine Sicherheitslücke in der Basic Authentication eines Webservice. Die Methode sollte übergebenen User und Passwort gegen die bekannten Werte prüfen. An einer Seite fehlte das this vor user und passwort, der übergebene User wurde also mit sich selbst verglichen. Jeder wäre durchgekommen.

Die betroffene Zeile war durch einen ersten Test bereits abgedeckt. Erst der Anspruch, auch alle Zweige des doppelten if zu treffen, erzwang die beiden Miss-Testfälle, und damit fiel der Fehler auf. Ohne Branch Coverage wäre er unsichtbar geblieben.

Trotzdem findet auch 100 Prozent nicht alles. Beim Advent of Code, einer Reihe täglicher Programmieraufgaben in der Weihnachtszeit, tauchte ein weiterer Bug in der Listenimplementierung auf. Es ging um eine while-Schleife, getestet mit null und einem Durchlauf. Der Fehler steckte in einem Index, der weit hinten in einem Durchlauf versetzt und erst im nächsten Durchlauf benutzt wurde.

Der Grund war ein klassisches Copy-Paste-Problem. Den Code gab es zweimal, einmal für Änderungen vorne, einmal hinten an der Liste. In der kopierten Variante stand ein Plus, wo ein Minus hingehört hätte, der Index lief in die falsche Richtung. Solche Fehler über Schleifengrenzen hinweg fängt Branch Coverage strukturell nicht ab.

Man macht Fehler an Stellen, wo man denkt, das ist zu trivial, da ist kein Fehler. Roger Butenuth

Die übrig gebliebenen Fehler waren wenige, aber sie waren da. Die Lehre daraus ist nicht Fehlerfreiheit, sondern Demut. Wo man früher Code einfach hinschrieb, lohnt sich ein zweiter Gedanke.

Warum eine Coverage-Vorgabe allein nichts bringt

Eine feste Prozentvorgabe steuert Qualität nicht zuverlässig. Jede Metrik, die man vorgibt, lässt sich austricksen, und genau das passiert dann auch. Eine Zahl als Ziel erzeugt Testfälle, die die Zahl erfüllen, nicht unbedingt Testfälle, die Fehler finden.

Coverage ist zudem nur so gut wie die Assertions dahinter. Es gibt Code mit ordentlicher Abdeckung und trotzdem unsinnigen Prüfungen. Eine hohe Coverage hilft dir nur mit vernünftigen Assertions, sonst misst sie ausschließlich, dass Code durchlaufen wurde.

Für die Auswahl der Abdeckung zählt der Kontext: Wofür entsteht die Software, wie kritisch ist sie, wie teuer ist ein Fehler im Betrieb. Aus dieser Frage folgt, wie weit du gehen solltest.

Coverage-ArtWas sie prüftAufwand
Line / Statementob eine Zeile ausgeführt wurdeniedrig
Branchob jeder Zweig durchlaufen wurdehoch

Exception-Handler sind ein gutes Beispiel für die Grenze. Sie sind schwer zu covern. Wenn ein Codebestand aber quer durchzogen ist von verstreuten Handlern, ist die bessere Frage nicht, wie man sie alle testet, sondern ob die Struktur überhaupt so aussehen sollte.

In realen Projekten sind 100 Prozent nicht erreichbar, und das ist in Ordnung. Statt an einer Metrik lässt sich Qualität eher an der Vernunft der Entwickler festmachen. Der Ort dieser Debatte verschiebt sich außerdem mit der Ebene: In Integrationsprojekten liegen die Probleme nicht in Unit-Tests, sondern zwischen den Systemen, wo eine sinnvolle Testabdeckung ganz anders aussieht.

Diese Seite teilen

Ähnliche Beiträge