Unit Tests are your Friends

Angeregt durch die Diskussion mit Teilnehmern eines Clean Code Developer Workshops habe ich mich wieder einmal mit der Frage befasst, welche Strategien beim automatisierten Testen ich anwende und empfehle. Die Diskussion drehte sich vor allem um die Frage, ob private Methoden durch Unit Tests getestet werden sollen und wie dies dann technisch am besten realisiert werden kann. Vordergründig geht es um die Herausforderung, dass private Methoden einem Test nicht so ohne weiteres zugänglich sind. Dahinter steht allerdings die Frage, ob private Methoden, mithin also Implementationsdetails, überhaupt isoliert getestet werden sollen.

Um meinen Standpunkt darzustellen möchte ich zunächst auf die Trennung von Integration und Operation eingehen. Das Integration Operation Segregation Principle (IOSP)  sagt aus, dass eine Methode entweder andere Methoden aufruft, diese also integriert, oder Details enthält. Gemeint sind hier Aufrufe von eigenen Methoden, die also zur Lösung gehören. Der Aufruf von Frameworkmethoden ist den Operationen vorbehalten. Operationen haben keine Abhängigkeit zu anderen Methoden der Lösung, sonst würden sie diese ja integrieren. Andersherum dürfen Integrationsmethoden keine Ausdrücke, API Aufrufe, etc. enthalten, sonst wären sie, neben ihrer Zuständigkeit für die Integration, gleichzeitig auch Operation.

Diese klare Trennung der Zuständigkeiten führt einerseits zu gut lesbarem und damit verständlichem Code. Dies ist die Voraussetzung für die Wandelbarkeit. Code den ich nicht verstehe, sollte ich besser nicht ändern. Im Kontext von automatisierten Tests sorgt das IOSP dafür, dass ich nun klar zwischen Integrations- und Unit Tests unterscheiden kann. Ein Unit Test testet eine Funktionseinheit (engl. Unit) in Isolation. Für Operationen gilt, dass sie bereits isoliert sind. Sie enthalten keine Methodenaufrufe, haben also keine Abhängigkeiten zu anderen Methoden der eigenen Lösung und sind somit Units.

Unit Tests allein genügen jedoch nicht, um die Korrektheit eines Softwaresystems sicherzustellen. Es werden immer auch Integrationstests benötigt. Nur durch echte Integrationstests kann sichergestellt werden, dass die zu integrierenden Funktionseinheiten zusammengenommen korrekt funktionieren. Werden im Integrationstest alle zu integrierenden Teile durch Attrappen ersetzt, handelt es sich nicht mehr um einen Integrationstest sondern wieder um einen Unit Test. Die Integrationseinheit wird von ihren Abhängigkeiten befreit und ist somit im Testkontext wieder eine Unit. So findet der Test allerdings nicht heraus, ob die Integration der realen Funktionseinheiten tatsächlich funktioniert, weil diese ja durch Attrappen ersetzt wurden.

Die Testpyramide: Systemtests, Integrationstests und Unit Tests

Als Fazit ergibt sich so die übliche Testpyramide, bestehend aus Systemtests, Integrationstests und Unit Tests.Tests Anzahl Integrationstests Unittests

Systemtests sind eine Variante von Integrationstests. Sie testen das komplette System, inkl. Benutzerschnittstelle und Ressourcenzugriffen. Integrationstests durchlaufen typischerweise nur Teile des Systems, insbesondere meist ohne die Benutzerschnittstelle.

Zerlegt man nun ein hinreichend großes Problem in kleinere Bestandteile, entsteht eine Hierarchie von Methoden. Am oberen Ende steht eine Integration. Diese integriert andere Funktionseinheiten, zuletzt auf der untersten Ebene dann nur noch Operationen. Der Einfachheit halber lasse ich in der folgenden Darstellung mehrere Ebenen der Integration außen vor. Nehmen wir das simple Beispiel einer Methode, die zur Erledigung ihrer Aufgabe drei Operationen aufruft.

Beispiel: ToDictionary
ToDictionary Flow Design Entwurf

ToDictionary Abhängigkeiten, Integration vs. Operation

In diesem Beispiel ist die Methode ToDictionary dafür zuständig, die drei Methoden SplitIntoSettings, SplitIntoKeyValuePairs und CreateDictionary zu integrieren, d.h. sie in der richtigen Weise aufzurufen. Die drei aufgerufenen Methoden sind Operationen, die sich jeweils um einen Teilaspekt des Problem kümmern. Sie rufen keine weiteren Methoden der Lösung auf, sondern enthalten die Details, sind Operationen.

In dieser Struktur können wir nun Integrationstests und Unit Tests unterscheiden. Wird im Test die Methode ToDictionary aufgerufen, stellt dies einen Integrationstest dar. Schließlich werden dabei die drei Operationen mit ausgeführt. Theoretisch wäre es möglich, die Methode ToDictionary von ihren Abhängigkeiten freizustellen, dann könnten wir einen Unit Test für die Integration schreiben. Der Erkenntnisgewinn wäre allerdings gering. Es würde sich nur zeigen, ob die drei Methoden in der richtigen Reihenfolge aufgerufen werden und die Parameter entsprechend durchgereicht werden. Ob das Verhalten der drei Methoden dazu führt, dass die Gesamtaufgabe korrekt gelöst wird, würde sich auf diese Weise nicht zeigen. Das stellt sich nur durch Integrationstest heraus.

Vermutlich werden die meisten Entwickler die insgesamt vier Methoden des ToDictionary Problem in der selben Klasse implementieren. Schließlich weicht keiner der Aspekte so stark von den anderen ab, das dies ein Grund wäre, eine der Methoden in einer andere Klasse auszulagern. Würde bspw. in einer der Methoden auf eine Datei zugegriffen, wäre dies ein Grund darüber nachzudenken, diese Methode in eine andere Klasse zu verlagern, um den Ressourcenzugriff von der Logik zu trennen.

Private Methoden

Bleiben also alle vier Methoden in der selben Klasse, wird man die Implementationsdetails auf die Sichtbarkeit private setzen wollen. Damit sind dann zunächst keine Unit Tests der drei Operationen mehr möglich, da ein Test von dieser eingeschränkten Sichtbarkeit genauso betroffen ist, wie Implementationsklassen. Genau an dieser Stelle beginnt nun die Diskussion. In C++ wäre ein Aufweichen der Sichtbarkeit zum Zwecke des Testens möglich, in dem die Implementationsklasse die Testklasse als friend markiert. Darüber hat bereits ein Kollege in folgendem Beitrag geschrieben:

https://arne-mertz.de/2015/08/unit-tests-are-not-friends/

Ich teile die Kritik an friend zum Zwecke der Testbarkeit ausdrücklich nicht. Es ist leider ein Versäumnis aller existierenden Programmiersprachen, dass es zwischen public und private keine Sichtbarkeit gibt die ausdrückt, dass das Symbol privat ist, gleichzeitig aber für Tests sichtbar sein soll. So etwas wie public for tests ist das, was hier gesucht ist. In Swift gibt es den testable import, der allerdings nur Interna sichtbar macht, keine privaten Methoden.

Gesucht ist also ein technischer Workaraound, mit dem private Methoden für Tests zugänglich gemacht werden. In .NET kann dazu internal in Kombination mit dem Attribut InternalsVisibleTo als Kompromiss verwendet werden, siehe meinen Beitrag zum Thema Sichtbarkeit. In C++ kann friend verwendet werden.

Doch dies sind technische Antworten, die nicht auf die Frage eingehen, ob es überhaupt sinnvoll ist, private Details einer Klasse zu testen. Betrachten wir erneut das ToDictionary Beispiel. Würden wir die drei Operationen auslagern in eine gesonderte Klasse, wären diese Methoden nun zwangsläufig public, damit sie aus der anderen Klasse verwendet und integriert werden können. In dem Fall würde sich niemand daran stören, dass die Operationen isoliert getestet werden. Schließlich sind sie ja public. Doch wo liegt der Unterschied zu Operationen die nur deshalb privat sind, weil sie zum gleichen Aspekt gehören wie die Integration? Natürlich verringern sich die potentiellen Abhängigkeiten, wenn Methoden privat gehalten werden. Das ist der Grund, dass die drei Operationen in der selben Klasse liegen sollten, wie ihre Integration ToDictionary. Trotzdem bleibt der Wunsch, sie isoliert zu testen. Die Trennung in Integrations- und Unit Tests (siehe Testpyramide) macht den Kern jeglicher Teststrategie aus: Mit den Integrationstests werden wenige „Brot und Butter“ Fälle getestet, die Unit Tests sind für die vielen Details zuständig. Alles durch Integrationstests zu testen würde eine kombinatorische Explosion bedeuten.

Black Box vs. White Box

Ein weiteres Argument lautet, dass automatisierte Tests keine Details der Implementation kennen sollen. Diese sogenannten Black Box Tests betrachten die Implementation ausschließlich über ihre öffentliche Schnittstelle. Das hat natürlich den Vorteil, dass bei umfangreichen Änderungen an den Details die Black Box Tests nicht modifiziert werden müssen. Auf der anderen Seite sind allerdings diese Tests in der Regel aufwendiger, da sie alle Aspekte der Lösung ausschließlich über die öffentliche Schnittstelle testen können.

Ich meine, wir sollten uns die Möglichkeit einer einfachen Teststruktur nicht dadurch verbauen, dass wir private Methoden zunageln. Natürlich müssen wir sicherstellen, dass sich in der restlichen Implementation niemand an privaten Methoden vergreift und diese nutzt. Dazu sind sie nicht gedacht. Werden sämtliche Methoden als öffentlich betrachtet, ergibt sich dadurch eine höhere Kopplung. Dieses Problem würde vermieden, wenn zwischen public und private die Sichtbarkeit testable eingeführt würde. Dann wäre jedem Entwickler ersichtlich, dass diese Methoden nicht verwendet werden dürfen. Der Compiler und die IDE würde darauf hinweisen. Im Test könnten die Methoden dennoch aufgerufen werden. Damit wäre eine Teststrategie, bestehend aus wenigen Integrationstests und vielen Unit Tests, leicht umsetzbar. In der Zwischenzeit begnüge ich mich mit den technischen Workaraounds und mache mir private Details über internal, friend oder ähnliche Mechanismen zugänglich. Korrektheit und damit automatisierte Tests haben einen sehr hohen Stellenwert. Pragmatismus ist gefragt, statt dogmatisch private Methoden von Tests auszunehmen.

Schreibe einen Kommentar