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:
- Die kniffligen Fälle beim Testen – GUI
- Die kniffligen Fälle beim Testen – Events
- Die kniffligen Fälle beim Testen – Exceptions
- Die kniffligen Fälle beim Testen – Sichtbarkeit
Ressourcenzugriffe wie das Lesen oder Schreiben von Dateien oder Datenbankzugriffe bereiten bei Unit Tests häufig Probleme. Ganz überwiegend liegt das daran, dass der Zugriff auf eine Ressource, wie eine Datei oder Datenbank, mit anderen Aspekten im Code vermischt ist. Innerhalb einer Methode wird dann sowohl auf die Ressource zugegriffen als auch mit den gelesenen Ergebnissen gearbeitet. Das Vermischen der Aspekte erschwert das automatisierte Testen. Doch bevor ich zu diesen schwierigen Fällen komme, soll es zunächst um reine Ressourcenzugriffe gehen.
Das Lesen einer Datei mit einem Unit Test automatisiert testen
Um für eine klare Trennung der Aspekte zu sorgen, müssen Ressourcenzugriffe immer getrennt werden vom Bearbeiten der gelesenen Daten. Im folgenden Beispiel ist eine Klasse FileAdapter für das Lesen und Schreiben von Strings in eine Datei zuständig.
using System.Collections.Generic; using System.IO; namespace ressourcen { public class FileAdapter { public IEnumerable Read(string filename) { return File.ReadAllLines(filename); } public void Write(string filename, IEnumerable content) { File.WriteAllLines(filename, content); } } }
Die Methode Read wird mit einem Dateinamen aufgerufen und liefert den Inhalt der Datei als Aufzählung von Strings. Dies automatisiert zu testen ist kein Problem, wie das folgende Listing zeigt.
using NUnit.Framework; namespace ressourcen.tests { [TestFixture] public class FileAdapterReadTests { private FileAdapter sut; [SetUp] public void Setup() { Directory.SetCurrentDirectory( TestContext.CurrentContext.TestDirectory); sut = new FileAdapter(); } [Test] public void Vorhandene_Datei_lesen() { var lines = sut.Read("testdaten\\textdatei.txt"); Assert.That(lines, Is.EqualTo(new[]{"z1", "z2", "z3"})); } } }
Eine Instanz der Klasse FileAdapter wird im Setup des Tests erzeug und im Feld sut, wie System under Test, abgelegt. Anschließend kann im Test auf die Instanz zugegriffen werden. Es erfolgt der Aufruf der Read Methode und ein anschließender Vergleich des Ergebnisses mit den erwarteten Strings.
Einbinden von Dateien in das Testprojekt
Die eigentliche Herausforderung besteht hier darin, die Datei für den Unit Test zuverlässig zur Verfügung zu stellen. Ein absoluter Pfad scheidet aus. Dieser mag auf dem Entwicklerrechner erreichbar sein, aber schon beim Continuous Integration Server wird dieser Test auf die Nase fallen, weil die Datei fehlt. Unit Tests müssen aber auf jedem Rechner nach dem Auschecken aus der Versionskontrolle ausführbar sein.
Im Read Aufruf wird als Dateiname „testdaten\textdatei.txt“ verwendet. Der Pfad zur Datei ist relativ angegeben. Das bedeutet, dass die Read Methode die Datei ausgehend von dem Verzeichnis sucht, welches während der Ausführung des Tests das aktuelle Verzeichnis ist. Für NUnit 2.x war das jeweils das Verzeichnis, in welches Visual Studio übersetzt hat, also das Output Directory (z.B. „bin\Debug“).
Mit NUnit 3.x ist dieses Verhalten leider geändert worden. Daher muss das aktuelle Verzeichnis zunächst auf das Ausführungsverzeichnis gesetzt werden. Ergänzen Sie dazu beim Einsatz von NUnit 3.x wie oben gezeigt in der Setup Methode die Zeile
Directory.SetCurrentDirectory(TestContext.CurrentContext.TestDirectory);
Durch diese Zeile wird das aktuelle Verzeichnis auf das Verzeichnis gesetzt, in dem die Assembly liegt, die den Test enthält, der gerade ausgeführt wird.
Die Datei „textdatei.txt“ habe ich im Testprojekt in einem Unterverzeichnis „testdaten“ abgelegt. Ferner habe ich das Attribut Copy to Output Directory gesetzt auf den Wert Copy if newer. Damit sorgt Visual Studio bei jedem Build Vorgang dafür, dass die Datei inkl. der Verzeichnisstruktur im „bin\Debug“ Verzeichnis angelegt bzw. aktualisiert wird. Somit kann sie im Test mit einem relativen Pfad geöffnet werden.
Das Schreiben einer Datei automatisiert testen
Der Test für das Schreiben verläuft folgendermaßen: Im Test wird eine Datei durch Aufruf der zu testenden Methode Write erzeugt. Anschließend wird die so erzeugte Datei vergleichen mit einer Datei, die den erwarteten Inhalt enthält. Folgendes Listing zeigt den automatisierten Test.
using NUnit.Framework; namespace ressourcen.tests { [TestFixture] public class FileAdapterWriteTests { private FileAdapter sut; [SetUp] public void Setup() { Directory.SetCurrentDirectory( TestContext.CurrentContext.TestDirectory); sut = new FileAdapter(); } [Test] public void Schreiben_einer_Datei() { sut.Write("daten.txt", new[] {"a", "b", "c"}); FileAssert.AreEqual("testdaten\\writeTest.txt", "daten.txt"); } } }
Im Test wird durch Aufruf der Write Methode die Datei „daten.txt“ erzeugt. Diese wird anschließend verglichen mit der Datei „testdaten\writeTest.txt“. Den Vergleich des Dateiinhalts führe ich mit NUnits FileAssert durch. Leider gibt es kein Gegenstück zu dieser Funktionalität in MSTest. Wieder ein Grund, von MSTest weg zu wechseln.
Einen integrierten Ressourcenzugriff automatisiert testen
Im folgenden Beispiel habe ich den Ressourcenzugriff mit dem Bearbeiten der gelesenen Daten in einer Methode zusammengefasst.
using System.Collections.Generic; using System.IO; namespace ressourcen { public class CsvReader { public IEnumerable<string[]> Read(string filename) { var lines = File.ReadAllLines(filename); foreach (var line in lines) { var fields = line.Split(';'); yield return fields; } } } }
Die Read Methode liest mittels File.ReadAllLines aus einer Datei und bearbeitet auch gleich den gelesenen Inhalt. Hier sind also zwei Aspekte vermischt.
Um diese Klasse zu testen, habe ich im Testprojekt eine Datei „testdaten\somerecords.csv“ angelegt, auf die im Test zugegriffen wird. Es handelt sich hierbei um einen Integrationstest, weil die beiden Aspekte Ressourcenzugriff und Bearbeiten der gelesenen Daten in der Methode Read zusammengefasst sind und im folgenden Beispiel auch zusammen getestet werden.
using System.IO; using System.Linq; using NUnit.Framework; namespace ressourcen.tests { [TestFixture] public class CsvReaderTests { [SetUp] public void Setup() { Directory.SetCurrentDirectory(TestContext.CurrentContext.TestDirectory); } [Test] public void CSV_Records_are_read_correctly() { var sut = new CsvReader(); var records = sut.Read("testdaten\\somerecords.csv").ToArray(); Assert.That(records[0], Is.EqualTo( new[] {"Title", "Price", "Author"})); Assert.That(records[1], Is.EqualTo( new[] { "Working Effectively with Legacy Code", "20", "Michael Feathers"})); } } }
Mit Mocks zu Unit Tests
So ohne weiteres lässt sich der Logikanteil der Methode Read nicht mit Unit Tests isoliert testen, da das Lesen aus der Datei mit der Logik zusammengefasst ist. Es handelt sich also um einen Integrationstest. Hier hilft wieder TypeMock Isolator. Mit diesem leistungsfähigen Mock Framework kann der Aufruf von File.ReadAllLines im Test beeinflusst werden. Im Test wird anstelle des Aufrufs der .NET Framework Methode eine Lambda Expression ausgeführt, die in der Testmethode angegeben ist. Diese liefert eine fest definierte Aufzählung von Strings zurück, so dass der Logik Anteil der Methode mit diesen Strings als Input getestet wird.
[Test, Isolated] public void Isolated_logic_test() { var sut = new CsvReader(); Isolate .WhenCalled(() => File.ReadAllLines("")) .WillReturn(new[] { "a;b;c", "1;2;3" }); var records = sut.Read("testdaten\\somerecords.csv").ToArray(); Assert.That(records[0], Is.EqualTo(new[] { "a", "b", "c" })); Assert.That(records[1], Is.EqualTo(new[] { "1", "2", "3" })); }
Der Einsatz von TypeMock Isolator oder vergleichbaren Werkzeugen soll nicht als Freifahrschein betrachtet werden. Es sind trotzdem die Prinzipien der Clean Code Developer Initiative einzuhalten. Zwar kann mit diesem Werkzeug die Testbarkeit hergestellt werden, aber die Wandelbarkeit wird dadurch kein Stück besser.
Soll das Programm bspw. so geändert werden, dass die Daten nicht mehr aus einer Datei gelesen sondern stattdessen von einem Webservice abgerufen werden, ist dies in der gezeigten Struktur nur schwer möglich. Es müsste dann sinnvollerweise erst der File.ReadAllLines Aufruf aus der Methode ausgelagert werden, bevor er durch einen Webservice Aufruf ersetzt wird.
Fazit
An Entwurf und Einhalten von Prinzipien geht trotz leistungsfähiger Werkzeuge kein Weg vorbei. Diese dienen allerdings dazu, Legacy Code unter Test zu stellen und schaffen damit die Voraussetzung für Refactorings. Leistungsfähige Werkzeuge sind nützlich und erfüllen ihren Zweck. Auf der anderen Seite muss der Umgang und die sinnvolle Verwendung des Werkzeugs erlernt werden, um keinen Schaden anzurichten.
Wie sind Ihre Erfahrungen im Umgang mit leistungsfähigen Mock Frameworks in Unit Tests? Schreiben Sie einen Kommentar!