Wie letzte Woche versprochen präsentiere ich heute ein mögliches Ergebnis der Lottery Kata. Dabei werde ich klären was es mit parametrisierten und datengetriebenen Tests auf sich hat, wie man sie mit NUnit umsetzt und welche Vor- bzw. Nachteile sich ergeben können. Die eigentliche Umsetzung der Logik ist nur am Rande interessant und wird größtenteils außen vor gelassen.

Notwendige Klassen

Damit der folgende Code verstanden werden kann, will ich dann aber doch zunächst die „Infrastruktur“ klären. Diese besteht bei mir aus zwei Klassen und einer Enumeration.

  • WinningClasses - Enumeration die alle möglichen Gewinnklassen enthält.
  • WinningClassCalculator - Klasse mit der eigentlichen Logik zum Lösen der Aufgabe.
  • Ticket - Klasse mit den Daten.

Da ich in der Aufgabenstellung nicht erwähnt habe, dass die Superzahl eigentlich mit der letzten Ziffer der Losnummer übereinstimmen muss, besitzt die Ticketklasse die regulär gezogenen bzw. getippten Zahlen als Liste, sowie zwei Properties AdditionalNumber und SuperNumber. Auf diese Weise muss man bei den Daten keine Typunterscheidung zwischen gezogenen und getippten „Tickets“ machen, wodurch die Sache etwas einfacher wird.

Der Test

Nun aber zum eigentlichen Test. Dieser kann wie gezeigt nur mit NUnit umgesetzt werden da hier ein Feature des Frameworks Verwendung findet, welches man mit MSTest o.ä. zwar auch umsetzen könnte aber dafür im Grunde den kompletten Code wieder umzuschreiben hat.

[Test, TestCaseSource(typeof(LottoTestDataFactory), "GetTestCases")]
public WinningClasses WinningClassCalulcator_shall_calculate_the_correct_winning_class(Ticket drawn, Ticket played)
{
    var sut = new WinningClassCalculator();
    return sut.CalculateWinningClass(drawn, played);
}

Vor allem wer schon einiges an Erfahrung mit Unit-Tests hat dürfte sich jetzt leicht verwundert die Augen reiben. Denn irgend wie stimmt an dem Code fast gar nichts.

Wann immer man über Unit Testing ließt wird einem gepredigt man solle sich an die drei großen A halten: Arrange, Act und Assert. Also das Konfigurieren der Umgebungsparamerter um den Testgegenstand in einen bestimmten Status zu versetzen (Arrange), das Ausführen der zu testenden Logik (Act) und das Überprüfen des Endzustandes gegen den Erwartungswert (Assert).

Was haben wir nun aber hier? Im Grunde doch nur das Act. Wo sind also die anderen beiden As abgeblieben? Im Framework, genauer gesagt werden sie uns mehr oder weniger vom Framework abgenommen. Ein Umstand der wohl nicht jedem gefallen wird, aber so manchen Vorteil mit sich bringt…

Datengetriebene Tests

Fassen wir noch einmal kurz zusammen welche Probleme es bei der Kata gab:

  • Die Testdaten haben sich nur leicht unterschieden.
  • Die Logik des Tests war bis auf die Daten immer gleich.
  • Das Endergebnis war jedoch immer ein anderes.

Daraus resultierend hatte man viel Testcode für verhältnismäßig wenig zu testende Logik. Dies ist insofern schlecht da man jeden Test auch prüfen und warten muss, immerhin könnte er ja selbst Fehler enthalten bzw. in Zukunft fehlerhaft werden.

Punkt zwei der obigen Aufzählung gibt uns bereits einen Hinweis wie wir nun unseren Test besser gestalten können. Dieser wird maßgeblich von den Daten bestimmt, warum also nicht die Testlogik gleich lassen und nur die Ausgangsdaten ändern?

NUnit gibt uns für diese sogenannten datengetriebenen Tests mehrere Möglichkeiten. Die einfachsten stellen dabei Attribute wie Values und Range dar. Hierbei wird die Testmethode parametrisiert und die Parameter werden je nach Konfiguration mit entsprechenden Daten belegt.

Auf die Weise müssen für unterschiedliche Eingabedaten nicht gleich unterschiedliche Tests geschrieben werden. Statt dessen wird für jeden Datensatz die eine Methode noch einmal ausgeführt und ihr Ergebnis entsprechend auch mehrfach bewertet.

Eine weitere Möglichkeit stellt das TestCase Attribut dar. Diesem können nicht nur die Werte für die einzelnen Parameter der Testmethode übergeben werden, sondern auch ein erwarteter Rückgabewert. Das Framework testet dann automatisch ob dieser Erwartungswert auch wirklich zurückgegeben wird und lässt jeden einzelnen Testfall fehlschlagen, falls dem nicht so ist.

Da wir jedoch Tickets und somit komplexe Datentypen vergleichen wollen, haben wir ein Problem. Der Aufbau der Testdaten ist zu komplex um das Arrange in den Attributen vorzunehmen, wir müssen es also auslagern. Aus diesem Grund schaffen wir uns eine Factory welche dann in den Attributen der Testmethode als TestCaseSource angegeben wird. Der zweite Parameter des Attributs spiegelt dabei den Namen der Factory Methode wieder.

public class LottoTestDataFactory
{
    public static IEnumerable GetTestCases()
    {
        var drawnTicket = new Ticket { Numbers = new List<int> { 44, 45, 46, 47, 48, 49 }, Additional = 17, Super = 9 };
        var classEightTicket = new Ticket { Numbers = new List<int> { 4, 5, 6, 47, 48, 49 }, Super = 0 };
        yield return (new TestCaseData(drawnTicket, classEightTicket).Returns(WinningClasses.VIII));
        // ...
        var classOneTicket = new Ticket { Numbers = new List<int> { 44, 45, 46, 47, 48, 49 }, Super = 9 };
        yield return (new TestCaseData(drawnTicket, classOneTicket).Returns(WinningClasses.I));
    }
}

Vor- und Nachteile

Die Vorteile liegen damit auf der Hand: Durch die Auslagerung der Daten können sehr schnell neue Tests hinzugefügt und vorhandene entfernt werden. Da alles automatisch abläuft ist die Fehlerwahrscheinlichkeit verhältnismäßig gering bzw. die Suche im Fehlerfall wird beschleunigt. Darüber hinaus sind die Tests recht einfach zu verstehen und brauchen keine lange Einlesezeit.

Wo Licht ist, ist aber immer auch Schatten und diesen darf man im gegebenen Fall auch gern differenziert betrachten. Wie schon gesagt, ist die Qualität der Tests maßgeblich von den Testdaten abhängig und damit von ihrer Quelle. Eine Unart von zum Beispiel MSTest finde ich ist, dass man dort die Daten nur aus Datenbanken oder Dateien ziehen, nicht aber im Code angeben kann. Sobald die Daten aber außerhalb meines Tests liegen, werden Änderungen daran wahrscheinlich die ich selbst nicht unter Kontrolle habe.

So etwas ist zum Beispiel der Fall wenn ein Kollege sieht, dass es ein XML-File mit Ticketinfos gibt. Die kann man ja super nutzen um die eigenen Tests zu steuern. Im nächsten Schritt werden dann aber Daten hinzugefügt die für meine eigenen Tests gar nicht relevant, ja im schlimmsten Fall sogar nicht mal valide sind. Als Ergebnis schlagen meine Tests fehl und ich suche den Fehler in der zu testenden Logik.


Kick It auf dotnet-kicks.de