Schnapsidee: xUnit.net ohne SetUp und TearDown
James Newkirk schreibt in seinem Blog, warum man nicht SetUp und TearDown in Tests nutzen sollte, Brad Wilson denkt ebenfalls darüber nach - und mich haut's aus TDD-Sicht lang hin bei der Argumenation!
Wir sind in der .NET-Welt. SetUp und TearDown sind zwei Methoden einer Testklasse des xUnit-Frameworks NUnit, die vor bzw. nach jeder Testmethode ausgeführt werden. SetUp und TearDown haben bei NUnit noch einen anderen Stellenwert als bei JUnit. Das kommt daher, dass bei NUnit nicht für jede Testmethode eine eigene Instanz der Testklasse erzeugt wird, wie dies bei JUnit der Fall wäre. Daher können keine Instanzvariablen bereits bei der Deklaration initialisiert werden, sondern man ist gezwungen, sie im SetUp zu initialisieren. Dadurch wird der Code aufgebläht. Wenn ich solche JUnit-Testklassen sehe...
public class FooTest {... dann wird da ganz schnell soetwas draus:
private Foo foo;
protected void setUp() {
foo = new Foo();
}
// Testmethoden
}
public class FooTest {In NUnit wäre das so nicht möglich. Was wollte man nun ändern, würde man ein neues Testframework für die .NET-Welt schaffen? Richtig, die Instanziierung von Testklassen pro Testmethode. Newkirk und Wilson wissen noch eine weitere Innovation: Sie entwickeln ein neues Testframework namens xUnit.net ohne (!) SetUp und TearDown, wie InfoQ zu berichten weiß.
private Foo foo = new Foo();
// Testmethoden
}
In ihren Blogeinträgen argumentieren Newkirk und Wilson unter anderem, dass man bei gegebenen SetUp- und TearDown-Methoden pro Test in drei Methoden reinschauen müsse und dass man dazu tendieren würde, auch nicht gemeinsam genutzte Attribute der Testmethoden ins SetUp bzw. TearDown zu packen und damit gegen das Single Responsibility Pattern verstoßen würde. Dabei zeigt Newkirk eine Testklasse, an der er das Problem verdeutlichen würde.
[TestFixture]
public class MoneyTest
{
private Money f12CHF;
private Money f14CHF;
private Money f7USD;
private MoneyBag moneyBag;
[SetUp]
public void BeforeTest()
{
f12CHF = new Money(12, "CHF");
f14CHF = new Money(14, "CHF");
f7USD = new Money(7, "USD");
moneyBag = new MoneyBag(f12CHF, f7USD);
}
[Test]
public void IsZero()
{
Money[] bag = { new Money(0, "CHF"), new Money(0, "USD") };
Assert.IsTrue(new MoneyBag(bag).IsZero);
}
[Test]
public void BagAdd()
{
Money[] bag = { new Money(26, "CHF"), new Money(7, "USD") };
MoneyBag expected = new MoneyBag(bag);
Assert.AreEqual(expected, f14CHF.Add(moneyBag));
}
[Test]
public void BagSubtractIsZero()
{
Assert.IsTrue(moneyBag.Subtract(moneyBag).IsZero);
}
}
Diese Klasse hat ganz klar einen Fehler, aber es ist nicht der, dass sie SetUp- und TearDown-Methoden besitzt, sondern dass sie zuviele unterschiedliche Dinge testet in einer einzigen Testklasse. Oder anders ausgedrückt: da sind mehrere Testfixtures vermischt.
Frank Westphal definiert Testfixtures so:
Ein Testfall sieht in der Regel so aus, daß eine bestimmte Konfiguration von Objekten aufgebaut wird, gegen die der Test läuft. Diese Menge von Testobjekten wird auch als Test-Fixture bezeichnet. Pro Testfallmethode wird meist nur eine bestimmte Operation und oft sogar nur eine bestimmte Situation im Verhalten der Fixture getestet.Und genau diese Testobjekte werden in dem von Newkirk gezeigten Code alle zusammen in die SetUp-Methode gepackt. Tatsächlich ist hier die SetUp-Methode überflüssig, weil die Fixtures keine Gemeinsamkeiten aufweisen. Aber dieser eine Fall zeigt mitnichten, dass die SetUp-Methode selbst überflüssig wäre. Wie sieht's denn mit gemeinsamen Fixtures aus? Sollte dann wirklich vor jeder Methode die gleichen Testobjekte erzeugt werden? DRY würde sich wundern: Wenn es beklagenswert wäre, drei Methoden im Blick zu haben, wie beklagenswert wäre es denn, jedesmal das gleiche im ersten Teil einer jeden Testmethode zu lesen? Wie nervig wäre es, wenn die Fixture angepasst werden müsste, und dies nicht an einer, sondern an zig Stellen passieren müßte? Und wie wollte man überhaupt die Fixtures erkennen und als solche identifizieren, wenn diese implizit in den Testmethoden versteckt sind?
Die richtige Antwort auf das Problem einer verkorksten Fixture wäre, mehrere Testklassen aus dieser einen hier zu extrahieren, sofern sich mehrere Fixtures herausbilden. Typischerweise orientieren sich die Testklassen eben genau an den Fixtures. Wer hier Testklassen zu getesteten Klassen im Verhältnis 1:1 schreibt, bringt sich um diese wichtige Erkenntnis der Fixtures und verletzt dann tatsächlich das Single Responsibility Prinzip. Ergo: Höre auf die Fixtures. Sie erzählen Dir, welche Testklassen Du brauchst.
Newkirk und Wilson argumentieren aber nicht nur über die Lesbarkeit oder den Mißbrauch von SetUp- und TearDown, sondern auch darüber, dass ohne SetUp/TearDown die Isolation der Tests nicht mehr gefährdet sei. Tatsächlich ist das ein Problem in der NUnit-Welt aufgrund oben genannter Nicht-Instanziierung der Testklassen pro Testmethode: Eine Instanzvariable lebt über alle Testmethodenausführungen einer Testklasse hinweg. Ohne SetUp und TearDown kann man keine Instanzvariablen mehr erzeugen, ohne die Tests voneinander abhängig zu machen, ergo ist das Abschaffen dieser Methoden eine gute Sache. Einspruch: Das ist keine gute Sache! Warum nicht einfach Instanzen pro Testmethode erzeugen, wie es viele andere xUnit-Frameworks auch tun? Tatsächlich ist dies auch ein Feature von xUnit.net, und vor diesem Hintergrund ist es mir unbegreiflich, warum SetUp und TearDown abgeschafft werden sollen.
Eine ganz wichtige Sache geht bei diesem Unfug auch noch über Bord: das Aufräumen nach dem Fehlschlagen von Tests. Wenn eine Zusicherung in einem Test fehlschlägt, dann wird garantiert, dass die TearDown-Methode ausgeführt wird. Wenn ich aber keine TearDown-Methode mehr habe, wie soll ich dann aufräumen, was noch offen rumliegt? Auf diese Art schaffe ich unangenehme Seiteneffekte: Test A öffnet eine Datei, sichert Dinge zu und schließt die Datei wieder. Ähnlich Test B: Dieser Test öffnet dieselbe Datei, sichert andere Dinge zu und schließt diese Datei ebenfalls wieder. In diesem Szenario wäre das Öffnen der Datei im SetUp und das Schließen im TearDown zu finden - wenn man denn diese Methoden hätte. Hat man sie nicht, dann steht entsprechender Code am Anfang und Ende von Test A und B. Angenommen, eine Zusicherung knallt, dann wird nachfolgender Code nicht mehr ausgeführt, die Datei also nicht mehr geschlossen für den Test, der danach kommt und der dann ebenfalls knallt. Super, wir bauen uns eine Abhängigkeit zwischen den Tests und hin ist sie, unsere Testisolation.
Merke: SetUp und TearDown sind gut. Es macht xUnit.net nicht zu einem besseren Testframework, Fixtures nicht mehr zuzulassen.
Und irgendwie scheinen Newkirk und Wilson das auch zu wissen, so ganz tief im Innern:
We believe that use of [SetUp] is generally bad. However, you can implement a parameterless constructor as a direct replacement. [...] We believe that use of [TearDown] is generally bad. However, you can implement IDisposable.Dispose as a direct replacement.Was bedeutet das jetzt? SetUp ist schlecht, aber wenn man es doch braucht, dann kann man das gewünschte Verhalten implizit über den Konstruktor erreichen? TearDown ist schlecht, aber wenn man es doch braucht, kann man es explizit über ein komplizierteres Konstrukt erreichen?
InfoQ fragte: "xUnit.net - Next Generation of Unit Testing Frameworks?" Ich meine: Nein, kein Fortschritt, sondern ein Rückschritt!