Das Thema Test Driven Development oder auch Test First Developent gewinnt immer mehr an Beachtung. Keine Konferenz, keine Zeitschrift, kein Sprecher der was auf sich hält kommt um das Thema herum. Doch nach dem überzeugenden Vortrag sitzt man zu Hause im Büro vor einem leeren Project und wie nun anfangen? Hier scheitern bereits die ersten, weil entsprechende Publikationen oft zwar die Vorteile ausführlich schildern, aber nicht den Einstieg darstellen. Deshalb möchte ich hier einen entsprechenden Einstieg geben und mit einem wirklich leeren Projekt beginnen.
Die Theorie um TDD will ich hier einfach weglassen. Hierzu gibt es bereits Informationen genug. Und wir werden verschiedene Vereinfachung vornehmen, über die Profis etwas die Nase rümpfen werden, aber damit erhalten wir ein einfaches und praktikable Einstiegsszenario.
Zum Einsatz kommen hierbei die Testfunktionen von Visual Studio 2008 die ab der Professional Edition enthalten sind. Wir beginnen mit einer komplett leeren Solution.
Die Frage, die nun im Raum steht, ist: Wie schreibe ich einen Test ohne eine Methode zu haben. Ein Unit-Test besteht ja im Prinzip darin, dass wir eine Methode aufrufen und den Rückgabewert mit einem Erwartungswert vergleichen. Der Test wird aber nicht einmal kompilieren, solange die Methode nicht definiert ist. Der Workaround an dieser Stelle sieht dann oft so aus, dass man von der Methode und ihrer Klasse erst einmal einen Stub anlegt der im wesentlichen eine “ThrowNotImplemented”-Exception wirft. Damit haben wir aber eigentlich schon mehr implementiert als nach dem TDD uns lieb ist.
Ein etwas eleganterer Ansatz geht über die Definition von Interfaces. Diese Vorgehensweise eignet sich besonders gut bei einer komponentenorientierten Architektur mit einem Contract First Ansatz. Dabei werden die Schnittstellen der einzelnen Komponenten erst über Contracts (Interfaces) beschrieben bevor diese implementiert werden. Den TDD-Ablauf Rot > Grün > Refactor erweitern wir ein wenig. Damit ergibt sich folgende Abfolge:
Contract definieren > Test implementieren > Rot > Funktion implementieren > Grün > Refactor
D.h. wir erstellen in einem ersten Schritt einen Contract (genau genommen machen wir damit kein TDD sondern ein Test First. Beim TDD ist der Test das erste was erstellt werden muss, aber das ist in meinen Augen eher Haarspalterei, so funktioniert es einfach in der Praxis). Wir erstellen ein neues ClassLibrary-Projekt und erstellen dort ein Interface.
1: namespace Contracts
2: { 3: public interface IOrderCalculator
4: { 5: decimal CalculateShippinghCosts(decimal sum, decimal freeShippingMin, decimal shippingCosts);
6: }
7: }
Wir wollen hier ein überschaubares, aber auch nicht zu triviales Beispiel verwenden. Die Methode CalculateShippingCosts soll zu einem gegebenen Rechnungsbetrag Versandkosten hinzuaddieren, wenn ein bestimmter Mindestbetrag nicht erreicht ist. So damit haben wir den Contract erstellt. Nun wollen wir einen Test dazu erstellen. Das geht am schnellsten durch einen Rechts-Klick auf die Methode und dann “Create Unit-Tests”.
Hier wird standardmäßig ein neues Test-Projekt angelegt. Darin wird ein entsprechender Unit-Test generiert.
1: [TestMethod()]
2: public void CalculateShippinghCostsTest()
3: { 4: IOrderCalculator target = CreateIOrderCalculator(); // TODO: Initialize to an appropriate value
5: Decimal sum = new Decimal(); // TODO: Initialize to an appropriate value
6: Decimal freeShippingMin = new Decimal(); // TODO: Initialize to an appropriate value
7: Decimal shippingCosts = new Decimal(); // TODO: Initialize to an appropriate value
8: Decimal expected = new Decimal(); // TODO: Initialize to an appropriate value
9: Decimal actual;
10: actual = target.CalculateShippinghCosts(sum, freeShippingMin, shippingCosts);
11: Assert.AreEqual(expected, actual);
12: Assert.Inconclusive("Verify the correctness of this test method."); 13: }
Damit können wir den Test bereits zum ersten mal ausführen und er geht wie erwartet auf Rot. Aber Moment, wie funktioniert das. Wie kann der Test eine Methode auf einem Interface aufrufen? Es gibt ja noch keine Implementierung des Interfaces und ein Interface selbst lässt sich ja nicht instanziieren. Hier baut Visual Studio einen kleinen Workaround. In Zeile 4 im obigen Code sieht man, dass eine Instanz des target-Objektes über die Methode CreateIOrderCalculator() erstellt wird. Diese Methode wollen wir mal etwas genauer anschauen.
1: internal virtual IOrderCalculator CreateIOrderCalculator()
2: { 3: // TODO: Instantiate an appropriate concrete class.
4: IOrderCalculator target = null;
5: return target;
6: }
Hier wird das Objekt einfach mit null initialisiert. Ein einfacher, aber wirkungsvoller Workaround. Damit erreichen wir unser Ziel, dass der Test kompiliert aber fehlschlägt. Nach der Implementierung ersetzen wir das null einfach durch die entsprechende Initialisierung. Damit können wir nun unsere Testcases Implementieren.
1: [TestMethod()]
2: public void CalculateShippinghCosts_Sum_Below_FreeShippingMin()
3: { 4: IOrderCalculator target = CreateIOrderCalculator();
5: Decimal sum = 1;
6: Decimal freeShippingMin = 10;
7: Decimal shippingCosts = 5;
8: // We are below min, so we have to add shippingCosts
9: Decimal expected = 6;
10: Decimal actual;
11: actual = target.CalculateShippinghCosts(sum, freeShippingMin, shippingCosts);
12: Assert.AreEqual(expected, actual);
13: }
14:
15: [TestMethod()]
16: public void CalculateShippinghCosts_Sum_Above_FreeShippingMin()
17: { 18: IOrderCalculator target = CreateIOrderCalculator();
19: Decimal sum = 20;
20: Decimal freeShippingMin = 10;
21: Decimal shippingCosts = 5;
22: // We are above min, so we don't add shippingCosts
23: Decimal expected = 20;
24: Decimal actual;
25: actual = target.CalculateShippinghCosts(sum, freeShippingMin, shippingCosts);
26: Assert.AreEqual(expected, actual);
27: }
28:
29: [TestMethod()]
30: public void CalculateShippinghCosts_Sum_Equal_FreeShippingMin()
31: { 32: IOrderCalculator target = CreateIOrderCalculator();
33: Decimal sum = 10;
34: Decimal freeShippingMin = 10;
35: Decimal shippingCosts = 5;
36: // We are equal min, so we don't add shippingCosts
37: Decimal expected = 10;
38: Decimal actual;
39: actual = target.CalculateShippinghCosts(sum, freeShippingMin, shippingCosts);
40: Assert.AreEqual(expected, actual);
41: }
Damit haben wir unser Szenario ausreichend beschrieben. Wir können nun an die Implementierung gehen.Dazu legen wir ein neues Projekt an in dem wir eine Klasse definieren die wir von unserem Interface ableiten.
1: namespace Components
2: { 3: public class cOrderCalculator : IOrderCalculator
4: { 5: public decimal CalculateShippinghCosts(decimal sum, decimal freeShippingMin, decimal shippingCosts)
6: { 7: // If sum is greater than Min then don't add shipping costs
8: if (sum > freeShippingMin)
9: return sum;
10: else
11: // else add shipping costs
12: return sum + shippingCosts;
13: }
14: }
15: }
Nun müssen wir unbedingt noch daran denken, die Initialisierung des Testobjektes in unserer Testmethode anzupassen.
1: internal virtual IOrderCalculator CreateIOrderCalculator()
2: { 3: IOrderCalculator target = new cOrderCalculator();
4: return target;
5: }
Nun können wir die Tests ausführen.
Oh, ein Test schlägt fehl. Bei genauerer Betrachtung stellen wir fest, dass wir bei der Implementierung den Fall dass die Summe gleich der Grenze ist nicht richtig berücksichtigt haben. Also hat sich hier der TDD-Ansatz schon bewährt und wir können den Fehler beheben. Damit sind alle Tests grün und wir können weiter fortfahren. Wir könnten nun z.B. auf unserem Interface weitere Methoden definieren und dafür Tests anlegen.
Also eigentlich gar nicht so schwer das mit dem TDD, oder? Freue mich auf euer Feedback.
Die Solution gibt es zum Download.
Happy Testing!