Über Unit-Test-Coverage

von Hubert Schmid vom 2011-11-27

Ich habe in der letzten Zeit mehrere Artikel zu dem Thema Unit-Test-Coverage gelesen. Das hat mich dazu bewogen, auch mal meine eigene Ansicht zu diesem Thema darzustellen, da diese anscheinend stark von dem abweicht, was ich an vielen Stellen wahrnehme. Ich bin selbst ein großer Verfechter von Qualitätsmaßnahmen in der Softwareentwicklung, und habe diese Position in all meinen Rollen stets vertreten – vom Software-Entwickler und -Architekt bis zum Projekt- und Ressourcen-Verantwortlichen. Und zu diesen Qualitätsmaßnahmen gehören für mich selbstverständlich auf umfangreiche Unit-Tests.

Problematisch finde ich es aber, wenn man solche Maßnahmen dogmatisch einsetzt. Damit meine ich den unreflektierten Einsatz, bei dem der Blick für die Realität und das eigentliche Ziel verloren geht. Und dazu möchte ich im Folgenden ein paar Punkte diskutieren, um mit aus meiner Sicht falschen Vorstellungen aufzuräumen.

Ist eine vollständige Testabdeckung hinreichend?

Diese Frage hört sich merkwürdig an. Ich meine damit, ob sich aus einer vollständigen Testabdeckung auf eine hohe Qualität schließen lässt. Die einfache Antwort darauf ist: Das hängt davon ab. Und zwar sowohl von der Qualitätsanforderung als auch von der Art der Testabdeckung.

Ich möchte das Thema nicht in der Breite betrachten und beschränke mich auf Systeme mit einfachen Qualitätsanforderungen und die für Unit-Tests in diesem Bereich üblicherweise eingesetzte Testabdeckung, das heißt irgendetwas zwischen Anweisungs- und Zweigüberdeckungstest. Und ja genau: Das beantwortet eigentlich schon die gestellte Frage. Denn selbst bei einer vollständigen Abdeckung sind die meisten Kontrollflussabhängigkeiten weiterhin ungetestet.

Ich habe ein einfaches Beispiel, um diese Aussage ein wenig greifbarer zu machen. Der folgende Code-Ausschnitt zeigt eine Klasse zum Sortieren eines int-Arrays. Ich habe mich dabei für den Algorithmus Selection-Sort entschieden. Der ist in der Praxis viel zu langsam, aber bietet sich für dieses Beispiel an, weil er einfach verständlich ist.

public void sort(long[] values) { int length = values.length; // selection sort for (int i = 0; i < length; ++i) { // determine minimum element int minIndex = i; for (int j = i + 1; j < length; ++j) { if (values[j] < values[minIndex]) { minIndex = j; } } // swap long t = values[i]; values[i] = values[minIndex]; values[minIndex] = t; } }

Man kann sich überlegen, wie viele Tests man eigentlich für eine vollständige Abdeckung dieser Implementierung benötigt. Und wenig überraschend wird man feststellen, dass bereits ein einziger Unit-Test dafür ausreichend ist. Beispielsweise die Sortierung des Arrays { 3, 2, 1 }. Andererseits behaupte ich, dass die meisten Entwickler die Ansicht teilen werden, dass für diese Funktionalität mehr Tests wünschenswert wären.

Die Code-Abdeckung ist in der Regel ungeeignet als Kriterium für die Güte der Tests. Eine geringe Code-Abdeckung kann lediglich einen Hinweis darauf geben, dass mehr Tests angemessen werden.

Nicht nur Unit-Tests!

Einer der wichtigsten Punkte für mich ist, dass man sich nicht nur auf eine oder wenige Arten des Testens beschränkt oder fokussiert. Man sollte sich immer wieder bewusst machen, dass Unit-Tests nur eine von sehr vielen Maßnahmen ist, um die Qualität von Systemen zu beeinflussen.

Im obigen Beispiel bietet sich beispielsweise eine Überprüfung des Funktionsergebnisses zur Laufzeit an. Das heißt am Ende der Sortierfunktion wird die Sortierreihenfolge nochmals überprüft, wie das in folgendem Fragment schematisch dargestellt ist.

public void sort(long[] values) { // ... (above sort implementation) if (!isSorted(values)) { // unreachable code throw new AssertionError(...); } }

Diese dauerhafte Prüfung zur Laufzeit (mit vernachlässigbarem Laufzeit-Overhead) bringt in vielen Fällen mehr als umfangreiche Unit-Tests. Im Gegensatz zu Unit-Tests werden dadurch nicht nur konstruierte und bekannt problematische Fälle abgedeckt. Und der Abbruch des Kontrollflusses durch das Werfen einer Ausnahme ist in vielen Fällen ausreichend – insbesondere weil dieser Code natürlich nicht nur im Produktionsbetrieb sondern auch während allen Tests mit ausgeführt wird.

Interessant im Zusammenhang mit Testabdeckung ist an diesem Beispiel auch, dass man bewusst auf die Möglichkeit einer vollständigen Code-Abdeckung verzichtet. Natürlich könnte man diese Möglichkeit durch eine kleine Umstrukturierung wieder schaffen. Aber man sollte sich trotzdem bewusst machen, dass eine vollständige Code-Abdeckung auch ein Zeichen für fehlende Laufzeitprüfungen – und damit Qualitätsmängel – sein kann.

Zu viele Unit-Tests?

Wie viele Unit-Tests braucht man eigentlich, um für eine bestimmte Funktionalität eine vollständige Testabdeckung zu erreichen? Die Frage mag zunächst merkwürdig erscheinen. Ich finde es aber wichtig zu erkennen, dass die Anzahl im Falle eines Anweisungs- oder Zweigüberdeckungstest in erster Linie nicht von der Funktionalität sondern von der Implementierung abhängt.

Andererseits haben Unit-Tests häufig den Anspruch, unabhängig von der konkreten Implementierung zu sein. Denn andernfalls würden sie die Refaktorisierung des Codes eher behindern als unterstützen. Und bei der Test-First-Entwicklung sollten die Unit-Tests sogar weitgehend abgeschlossen sein, bevor die erste Zeile der Implementierung überhaupt existiert.

Ich behaupte, dass bei normalen Qualitätsansprüchen der Umfang der Unit-Tests im Mittel ungefähr 50% des Umfangs des zu testenden Codes entsprechen sollte – unter der Voraussetzung einer sinnvollen Metrik für die Größe des Codes, die die Entropie berücksichtigt. Wird eine hohe Testabdeckung mit deutlich weniger Unit-Tests erreicht, dann deutet das darauf hin, dass viele Kontrollflusspfade nicht berücksichtigt wurden (wie im obigen Beispiel). Werden hingegen deutlich mehr Unit-Tests benötigt, um eine (fast) vollständige Testabdeckung zu erreichen, dann deutet das darauf hin, dass die Implementierung der zu testenden Funktionalität zu kompliziert ist.

Vielleicht mache ich noch ein Beispiel dazu: Vor einiger Zeit habe ich einen Blog-Artikel gelesen, in dem für eine relativ einfache Funktionalität die fehlenden Unit-Tests gesucht wurden, um die Testabdeckung zu vervollständigen. Mit einem kurzen Blick auf die Tests war diese Frage für mich nicht zu beantworten, denn diese waren über 300 Zeilen lang und entsprachen vom Umfang ungefähr 115% der zu testenden Implementierung (unter Verwendung einer sinnvollen Metrik). Im Code war das Problem auch nicht direkt ersichtlich. Erst mit genauerer Analyse konnte ich feststellen, dass die nicht abgedeckten Code-Zeilen überhaupt nicht erreicht werden können.

Allerdings ist mir bereits beim ersten Blick auf die Implementierung aufgefallen, dass diese anscheinend viel zu kompliziert ist. Ich habe in wenigen Minuten die Implementierung komplett neu geschrieben, drei Unit-Tests dafür angelegt und siehe da: Vollständige Testabdeckung erreicht.

Fazit!

Meiner Meinung nach ist die Messung der Testabdeckung ein nützliches Hilfsmittel im Zusammenhang mit Unit-Tests. Allerdings sollte man sie nicht als einzige Indikation verwenden, denn eine hohe Testabdeckung ist weder hinreichend noch notwendig für eine sinnvolle Auswahl der Unit-Tests. Wirkungsvoll wird die Messung erst, wenn sie in Relation zu anderen Metriken gesetzt wird.

Darüber hinaus sollte man nicht vergessen, dass Unit-Tests nicht die einzige Qualitätsmaßnahme sind. Und bevor man zu viel Zeit in diese Tests investiert, sollte man sich überlegen, ob man nicht die Qualitätsansprüche viel einfacher erreichen kann, wenn man den Code selbst verbessert und ganz andere Arten von Tests durchführt.