Bild von Stefan Lieser
Stefan Lieser

Die kniffligen Fälle beim Testen – Sichtbarkeit

Bei diesem Beitrag handelt es sich um eine überarbeitete Version. Er wurde zuvor bereits bei refactoring-legacy-code.net veröffentlicht.

Dies ist eine Beitragsserie. Die weiteren Beiträge finden Sie hier:

Sind Sie schon über das Attribut InternalsVisibleTo gestolpert? Es ermöglicht Ihnen das Testen von internal Methoden. Warum ich das für sinnvoll halte erläutert der folgende Beitrag.

Blackbox oder Whitebox?

Beim Testen unterscheiden wir Blackbox und Whitebox Tests. Bei Blackbox Tests wird die zu testende Funktionseinheit nur von außen betrachtet. Wir betrachten sie als „black box“, in die wir keinen Einblick haben. Wie es innen drin aussieht, wie diese Box funktioniert, das sehen wir nicht. Bei Whitebox Tests dagegen wissen wir, wie die Box von innen aussieht und wie sie funktioniert. Wir kennen alle Details, den Algorithmus, die Interna. Dieses interne Wissen nutzen wir in Whitebox Tests gezielt aus, während wir bei Blackbox Tests ausschließlich über die öffentliche Schnittstelle testen.

Sichtbarkeit

 

Einige Kollegen vertreten die Ansicht, dass automatisierte Tests immer Blackbox Tests sein sollten. Die Tests sollten sich nur auf die öffentliche API beziehen. Andernfalls müssten Tests angepasst werden, sofern die interne Implementation verändert würde.

Aus einer rein technischen Perspektive kann ich dem Argument zustimmen. Sobald ich im Whitebox Test Interna verwende, sind meine Tests davon abhängig, dass die Interna nicht verändert werden. Andererseits habe ich es nur selten erlebt, dass die Implementation tatsächlich geändert wurde. Und dann sind trotzdem Blackbox Tests kaputt gegangen.

Am Ende geht es bei der Frage Whitebox oder Blackbox darum abzuwägen, zwischen Stabilität der Tests einerseits und leichter Testbarkeit andererseits. Für mich haben Whitebox Tests einen hohen Wert.

Sie ermöglichen es mir, eine gesunde Struktur von Tests aufzubauen: wenige Integrationstests über die öffentliche API, viele Unittests auf Interna. Die folgende Abbildung zeigt, wie die Anzahl von Tests verteilt sein sollte.

automatisiertes Testen - Clean Code Developer Akademie und Trainings

Aspekte trennen: Integration vs. Operation

 

Bei der Implementation beachte ich stets das IOSP, das Integration Operation Segregation Principle. Es besagt, dass eine Methode entweder Integration oder Operation sein soll. Der folgende Ausschnitt zeigt das an einem einfachen Beispiel:

public class Configuration
{

    public static IDictionary<string, string> ToDictionary(string configuration) {
        var settings = SplitIntoSettings(configuration);
        var keyValuePairs = SplitIntoKeyValuePairs(settings);
        var dictionary = CreateDictionary(keyValuePairs);
        return dictionary;
    }

    internal static IEnumerable SplitIntoSettings(string configuration) {
        return configuration.Split(';');
    }

    internal static IEnumerable<KeyValuePair<string, string>> SplitIntoKeyValuePairs(
            IEnumerable settings) {
        foreach (var setting in settings) {
            var keyAndValue = setting.Split('=');
            yield return new KeyValuePair<string, string>(
                keyAndValue[0], keyAndValue[1]);
        }
    }

    internal static IDictionary<string, string> CreateDictionary(
            IEnumerable<KeyValuePair<string, string>> keyValuePairs) {
        var result = new Dictionary<string, string>();
        foreach (var keyValuePair in keyValuePairs) {
            result.Add(keyValuePair.Key, keyValuePair.Value);
        }
        return result;
    }
}

Die Methode ToDictionary gehört zur Kategorie Integration. Diese Methode ist für den Aufruf weiterer Methoden zuständig. Sie integriert diese Methoden. Die Methoden SplitIntoSettings, SplitIntoKeyValuePairs und CreateDictionary sind dagegen Operationen. Diese Methoden enthalten die Domänenlogik und rufen keine weiteren Methoden auf, von Methoden aus Frameworks einmal abgesehen.

Domänenlogik – Logik, die das Thema der Anwendung betrifft.

Teststrategie

 

Bei der klaren Trennung von Integration und Operation ergibt es großen Sinn, die Operationen isoliert zu testen. Diese Methoden sind Blätter im Abhängigkeitsbaum, sind also ohnehin schon isoliert. Die Integrationsmethoden dagegen haben Abhängigkeiten zu den Methoden, die sie integrieren. Sie isoliert zu testen würde bedeuten, die realen Methoden im Test durch Attrappen zu ersetzen. Technisch ist das zwar möglich, doch steht der Nutzen in keinem Verhältnis zum Aufwand. Die Integrationsmethoden teste ich also nur mit Integrationstests. Folglich habe ich ich wenige Integrationstests über die öffentliche API und viele Unittests für die Operationen.

Für das Beispiel ToDictionary benötige ich lediglich einen einzelnen Integrationstest. Die Methode besteht aus einem linearen Ablauf von Methodenaufrufen. Es gibt keine Verzweigung und keine Schleife. Folglich ist die Testabdeckung dieser Methode bereits mit einem einzigen Integrationstest erreicht. Ich teste hier mein Standardbeispiel und prüfe, ob der folgende String korrekt in ein Dictionary transformiert wird:

"a=1;b=2;c=3" -> {{"a", "1"}, {"b", "2"}, {"c", "3"}}

Die vielen syntaktischen Details dieser Transformation teste ich dann mit Unit Tests der Operationen.

  • Ob der String korrekt an den Semikolons zerlegt wird, teste ich mit Unittests auf der Methode SplitIntoSettings.
  • Das Zerlegen eines einzelnen Settings in Key und Value am Gleichheitszeichen teste ich mit Unittests auf der Methode SplitIntoKeyValuePairs.
  • Das korrekte Aufbauen des Dictionaries teste ich mit Unittests auf der Methode CreateDictionary.

Diese Vorgehensweise hat den Vorteil, dass die einzelnen Tests leichter zu formulieren sind, als bei reinen Integrationstests über die öffentliche API. Das liegt vor allem daran, dass der Aufbau der jeweiligen Testdaten einfacher und fokussierter ist. Die Methode SplitIntoSettings kann ich bspw. mit folgenden Testfällen überprüfen:

"a;b" -> "a", "b"
"a;;b" -> "a", "b"
";a" -> "a"
"a;" -> "a"

Diese Testfälle sind einfacher zu formulieren, weil es nicht relevant ist, welche Syntax die Settings erfüllen müssen (so bezeichne ich in diesem Beispiel die Strings zwischen den Semikolons).

Insgesamt ergibt sich der Vorteil, dass bei einem Problem meist ein einzelner Unittest fehlschlägt. Dieser zeigt mir damit sehr fokussiert an, wo genau das Problem liegt. Würden alle Tests ausschließlich über die öffentliche API gehen, schlagen typischerweise bei einem Problem mehrere Integrationstests fehl und es ist somit unklar, welches Detail das Problem verursacht.

Sichtbarkeit mit InternalsVisibleTo öffnen

 

Bleibt noch die Frage, wie die Operationen in den automatisierten Unittests aufgerufen werden können. Normalerweise würden die Interna der Klasse auf private gesetzt. Damit wären die Operationen für automatisierte Tests nicht ohne weiteres erreichbar. Ich könnte sie per Reflection aufrufen. Doch das macht mir zu viel Mühe. Mal ganz davon abgesehen, dass ich dann beim Refactoring aufpassen müsste, weil die Methodennamen dann als Zeichenketten in den Tests auftauchen würden.

Also setze ich die Operationen auf internal und ergänze das InternalsVisibleTo Attribut auf der Implementationsassembly. Beachten Sie, dass Sie den Assemblynamen der Assembly angeben müssen, der Sie den Zugriff auf die internal Symbol gestatten möchten. Nach Anlegen eines neuen Projekts entspricht der Assemblyname dem Projektname. Sollten Sie den Projektnamen allerdings ändern, bleibt der ursprüngliche Assemblyname zunächst erhalten. Im InternalsVisibleTo muss zwingend der Assemblyname stehen, nicht der Projektname.

InternalsVisibleTo - automatisiertes Testen - Clean Code Developer Akademie - Stefan Lieser

 

Durch das InternalsVisibleTo Attribut sind die Interna der Klasse für die Testassembly sichtbar und können daher automatisiert getestet werden. Es handelt sich hier um Whitebox Tests, weil die Tests nun Kenntnis haben über die interne Struktur und die Funktionsweise der Implementation.

Allerdings sind die Interna nun auch in der Implementationsassembly sichtbar. Die mit internal markierten Methoden der Klasse können aus anderen Klassen innerhalb derselben Assembly aufgerufen werden. Damit ist die Sichtbarkeit der Interna nicht nur auf die Tests ausgedehnt, sondern leider auch auf die Implementationsassembly. Diesen Nachteil nehme ich zugunsten der guten Testbarkeit in Kauf. Innerhalb des Teams muss die Vorgehensweise allen Entwicklern bekannt sein um zu vermeiden, dass Abhängigkeiten zu internal Methoden eingegangen werden. Ganz pragmatisch gesehen halte ich das für keinen Nachteil. Auch ohne diese Teststrategie sollten Entwickler keine Abhängigkeit zu internal Methoden eingehen, ohne gut darüber nachzudenken, was dies für Folgen haben könnte. Für mich steht internal auf derselben Ebene wir private: internes, privates Zeugs, von dem man die Finger lässt. Regelmäßige Code Reviews können im Team dafür sorgen, dass Probleme mit Abhängigkeiten rechtzeitig erkannt werden.

Fazit

 

Whitebox Tests ermöglichen es mir, auf der Ebene von Methoden zwischen Integrationstests und Unittests zu unterscheiden. Integrationstests, bezogen auf die Methoden, testen die öffentliche API, während Unittests auf Interna der Lösung abzielen. Auf diese Weise gelange ich zu einer gesunden Verteilung der Anzahl der Tests: wenige Integrationstests, viele Unittests.

Die Sichtbarkeit der Interna muss für diese Teststrategie etwas aufgeweicht werden, da C# keine spezielle „nur für Tests offen“ Sichtbarkeit bietet, die zwischen private und internal liegen müsste.

Unsere Seminare

course
Clean Code Developer Basics

Prinzipien und Tests – Das Seminar wendet sich an Softwareentwickler, die gerade beginnen, sich mit dem Thema Softwarequalität auseinanderzusetzen. Es werden die wichtigsten Prinzipien und Praktiken der Clean Code Developer Initiative vermittelt.

zum Seminar »
course
Clean Code Developer Advanced

Mit Flow Design von den Anforderungen zum Clean Code – Lernen Sie mit Flow Design einen Softwareentwicklungsprozess kennen, der Sie flüssig von den Anforderungen zum Clean Code führt.

zum Seminar »
course
Clean Code Developer Trainer

Seminare als Trainer durchführen – Dieses Seminar wendet sich an Softwareentwickler, die ihr Wissen über die Clean Code Developer Prinzipien und Praktiken bzw. über Flow Design als Trainer an andere weitergeben möchten.

zum Seminar »
course
Clean Code Developer CoWorking

Online CoWorking inkl. Coaching –
Wir werden häufig gefragt, was man als Entwickler tun könne, um kontinuierlich dran zu bleiben am Thema Clean Code Developer. Unsere Antwort: Treffen Sie sich regelmäßig wöchentlich online mit anderen Clean Code Developern.

zum Seminar »

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

de_DEGerman