Die kniffligen Fälle beim Testen – Sichtbarkeit

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.

Tests Anzahl Integrationstests UnittestsAspekte 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<string> SplitIntoSettings(string configuration) {
        return configuration.Split(';');
    }

    internal static IEnumerable<KeyValuePair<string, string>> SplitIntoKeyValuePairs(
            IEnumerable<string> 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 Attribut

 

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.

Bonus: Schnellstart Unit Tests mit NUnit

In diesem PDF finden Sie alle wichtigen Details zu automatisierten Tests mit NUnit.

 

 

4 Gedanken zu „Die kniffligen Fälle beim Testen – Sichtbarkeit“

  1. InternalsVisibleTo in Verbindung mit dem access specifier internal ist vergleichbar mit Revisionsklappen am Flugzeug und der regelmässigen Kontrolle, ob alles in Ordnung ist. Ein Flugzeug ausschließlich „blackbox“ zu testen wäre nicht sinnvoll und könnte fatale Folgen haben. Es nur „whitebox“ zu testen widerspricht allerdings auch dem gesunden Menschenverstand. Eine pragmatische Mischung machts, wobei ich die Pyramide mit den Integrationstests oben und den Unittests unten sehr gelungen finde.

    Antworten
  2. Von diesem Attribut habe ich auch nicht gehört 🙂 Ich habe bisher die internen Methoden als protected deklariert und eine „Class under test“ erzeugt, die von dieser Klasse abgeleitet war und nur blind ihre Methoden aufgerufen hat. Das Attribute InternalsVisibleTo hat aber seinen Charme und verringert den beim Testen erzeugten Overhead. Muss ich gleich ausprobieren…

    Antworten
  3. Wie ist der Tradeoff in .NET, wenn man die Unit-Tests im selben Assembly neben dem Produktivcode ablegen würde?
    Kann man sie beim Kompilieren der Assembly ausschliessen bzw. ist es schlimm, wenn sie im Assembly bleiben?

    Antworten

Schreibe einen Kommentar