How-To: Datengetriebene Tests mit MS Test

In einem früheren Post hatte ich einmal beschrieben wie Data-Driven-Tests mit NUnit umgesetzt werden können. Damals habe ich bereits darauf hingewiesen, dass so etwas theoretisch auch mit MS Test und dem Visual Studio möglich ist, was ich nun erläutern möchte.

Das Beispielszenario

Das Szenario ist im Grunde wie im vorangegangenen Artikel. Es geht darum die Logik für die Berechnung von Lottoergebnissen zu programmieren. Die dafür verwendeten Klassen unterscheiden sich jedoch, was hauptsächlich darin begründet ist, dass mein Programm mittlerweile ein wenig gewachsen ist und ich zu faul war extra ein Projekt nur für dieses Post hier aufzusetzen.

Grundsätzlich haben wir deshalb eine Klasse LottoTicket die die Ticketdaten des Spielers enthält. Dazu gehören alle Tipps die er auf dem Ticket gemacht hat, sowie eine eindeutige Referenznummer aus der sich die Superzahl ergibt. Weiterhin haben wir eine Klasse LottoDrawing mit den Daten der Ziehung.

Last but not least haben wir den PrizeCategoryCalculator den wir auch testen wollen. Er errechnet die Gewinnklassen aller Tipps eines Tickets, damit, wenn die Quoten feststehen, der Gesamtgewinn des Spielscheins errechnet werden kann.

Die Besonderheit ist nun, dass alle möglichen Berechnungen von Gewinnklassen überprüft werden sollen ohne einen allzu großen Aufwand im Test zu induzieren.

Der erste Wurf

Ein einzelner Test kann in etwa wie der folgende aussehen. Dabei fällt auf, dass die ersten 18 Zeilen  nur dem Aufsetzen der Testdaten (Arrange) dienen und, abgesehen von den tatsächlich verwendeten Werten, in allen folgenden Tests nahezu gleich bleiben. Hier wäre es also sehr hilfreich, wenn die Testmethode nur ein einziges Mal geschrieben und dann vom entsprechenden Framework mit Testdaten versorgt würde.

var sut = new PrizeCategoryCalculator();
var numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
var givenTipp = new LottoTipp { RegularNumbers = numbers };

var givenDrawing = new LottoDrawing
{
   AdditionalNumber = 11,
   Date = DateTime.Now,
   RegularNumbers = numbers,
   SuperNumber = 9
};

var givenTicket = new LottoTicket
{
   RegistrationNumber = "02132309" + givenDrawing.SuperNumber,
   Tipps = new List<LottoTipp> { givenTipp }
};

var results = sut.Check(givenTicket, givenDrawing);
var result = results.FirstOrDefault();

Assert.IsNotNull(result);
Assert.AreEqual(PrizeCategory.I, result);

Testdaten bereitstellen

Um Testdaten mit MS Test bereit zu stellen, bedarf es zunächst einer Datenquelle. Diese kann entweder in Form einer Datenbank oder einer CSV bzw. XML Datei vorliegen. Bei CVS-Dateien ist es wichtig, dass alle Werte durch Kommas separiert sind was mit Excel nicht immer der Fall ist.

Um diese Datei einzubinden muss nun die Test View im Visual Studio über das Menü Tests -> Windows -> Test View geöffnet werden. Darin sind alle Tests zu sehen die aktuell verfügbar sind, desweiteren kann über F4 das Property Window des entsprechenden Tests geöffnet werden in dem zusätzliche Einstellungen verfügbar sind.

In genau diesem Eigenschaftsfenster ist danach der Wert Data Connection String auszuwählen, der leer sein müsste. Erst wenn er ausgewählt wurde erscheint auf der rechten Seite ein Button mit drei Punkten über diesen startet man einen Wizard zum Auswählen der tatsächlichen Daten.

Stimmt die Formatierung der Daten, sollte der letzte Dialog im Wizard in etwa wie folgt aussehen. Befinden sich alle Daten in nur einer Spalte, wurden bei CSV Dateien keine Kommas zum Trennen der Werte und bei XML eine ungünstige oder nicht wohl geformte Sturktur genutzt. Ein Klick auf Finish fügt anschließend die entsprechenden Attribute an den jeweiligen Test.

Bei diesen Attributen handelt es sich zum einen um DataSource mit welchem die Datenquelle beschrieben und dem, neben Informationen über den Typ der Quelle, auch deren Pfad und eine etwas sinnlos wirkende Angabe eines Tabellennamens übergeben wird. Darüber hinaus bedaruf es der Spezifizierung einer Datenzugriffsmethode, welche theoretisch entweder sequenziell oder zufällig geschehen kann. Praktisch funktioniert bei Dateien als Quelle, meines Wissens aber nur sequenziell…

[DataSource("Microsoft.VisualStudio.TestTools.DataSource.CSV",
            "|DataDirectory|\\WinCheck.csv",
            "WinCheck#csv",
            DataAccessMethod.Sequential)]
[DeploymentItem("MS_Test\\UnitTests\\WinCheck.csv")]

Das zweite Attribut, das automatisch angelegt wurde, ist das DeploymentItem. Über dieses werde ich sicher noch ein weiteres Post schreiben, denn es wir immer dann genutzt wenn automatisch Dateien oder andere Arten von Ressourcen deployt werden sollen. Darüber hinaus hat es einige Macken die man kennen sollte. Im aktuellen Fall reicht es aber zu wissen, dass die als Parameter angegebene Datei automatisch von Framework in das Ausgabeverzeichnis des Tests kopiert wird.

Testdaten verwenden

Nun werden die Daten vom Framework geliefert, doch wie greift man darauf zu? Hierzu braucht unsere Testklasse die Property TestContext vom gleichnamigen Typ. Diese enthält wiederum eine Reihe von Informationen die während eines Testdurchlaufs von Interesse sein könnte wie zum Beispiel das Verzeichnis in dem die Programmdaten liegen und eben auch die Daten aus unserer Quelle.

Auf jene wird dann in der eigentlichen Testmethode durch die Row Property des Contexts zugegriffen. Eine solche Row ist mit einer Zeile in den Testdaten gleich zu setzen, wobei die einzelnen Spaltenwerte über ihren Titel identifiziert werden.

var drawing = new LottoDrawing();
drawing.AdditionalNumber = Convert.ToInt32(this.TestContext.DataRow["Additional Number"]);
drawing.SuperNumber = Convert.ToInt32(this.TestContext.DataRow["Super Number"]);

Das zeilenweise Auslesen der Daten übernimmt MS Test für uns, welches die Test-Methode für jede Zeile erneut aufruft. Schlägt der Test für eine Zeile fehlt, werden alle anderen dennoch ausgeführt und gehen auch jeweils einzeln mit ihren Ergebnissen in die Auswertung ein. Zumindest wenn man den Testrunner von Visual Studio nutzt. Ist man, wie ich, überzeugter Nutzer des Resharpers erhält man von diesem nur das Gesamtergebnis aller Testfälle und darüber hinaus keine aufgeschlüsselte Fehlerbeschreibung falls etwas schief geht.

Die gesamte Testklasse sieht anschließend wie folgt aus, wobei jedes Ticket nur einen einzelnen Tipp enthält um die Komplexität an Testdaten möglichst gering zu halten.

public TestContext TestContext { get; set; }

[DataSource("Microsoft.VisualStudio.TestTools.DataSource.CSV",
            "|DataDirectory|\\WinCheck.csv",
            "WinCheck#csv",
            DataAccessMethod.Sequential)]
[DeploymentItem("MS_Test\\UnitTests\\WinCheck.csv")]
[TestMethod]
public void PrizeCategoryCalculator_shall_give_correct_prize_category_for_each_tipp()
{
   var ticketNumbers = new List<int>();
   var drawingNumbers = new List<int>();
   for (var i = 1; i < 7; i++)
   {
      var number = Convert.ToInt32(this.TestContext.DataRow["Ticket Number " + i]);
      ticketNumbers.Add(number);

      number = Convert.ToInt32(this.TestContext.DataRow["Drawing Number " + i]);
      drawingNumbers.Add(number);
   }

   var tipp = new LottoTipp { RegularNumbers = ticketNumbers };
   var ticket = new LottoTicket
   {
      Tipps = new List<LottoTipp> { tipp },
      RegistrationNumber = this.TestContext.DataRow["Registration Number"].ToString()
   };

   var drawing = new LottoDrawing();
   drawing.AdditionalNumber = Convert.ToInt32(this.TestContext.DataRow["Additional Number"]);
   drawing.SuperNumber = Convert.ToInt32(this.TestContext.DataRow["Super Number"]);
   drawing.RegularNumbers = drawingNumbers;

   var expectedResult = (PrizeCategory)Enum.Parse(
           typeof(PrizeCategory), this.TestContext.DataRow["Expected Result"].ToString());

   this.ActualTestImplementation(ticket, drawing, expectedResult);
}

public void ActualTestImplementation(LottoTicket givenTicket, LottoDrawing givenDrawing,
                                         PrizeCategory expectedResult)
{
   var sut = new PrizeCategoryCalculator();

   var results = sut.Check(givenTicket, givenDrawing);
   var result = results.FirstOrDefault();

   Assert.IsNotNull(result);
   Assert.AreEqual(expectedResult, result);
}

Fazit

Aus Entwicklersicht ist die Auslagerung der Daten in eine Datei sehr unangenehm. Während dies in reinen Testabteilungen sicher der Konsulidierung von Testdaten dient, ist es für Unit Tests reichlich kompliziert, unübersichtlich und entzieht sie jeder Art von automatischem Refaktoring. Darüber hinaus ist es außerdem fehleranfällig da die Daten nicht typischer vorliegen sondern erst konvertiert werden müssen und der Zugriff auf die Spalten in Form von Magic Strings geschieht.