Die Community zu .NET und Classic VB.
Menü

Test Driven Development - Eine Einführung (Teil 2)

 von 

Einleitung 

Dieser Artikel stellt die Fortsetzung des Tutorials zur testbasierten Entwicklung dar. Er knüpft direkt an den ersten Teil an.

Der Umbau  

Wir haben im letzten Teil einen einfachen Stack implementiert. Diesen Stack haben wir auf einer verketteten Liste aufgebaut. Weil wir es aber einfach einmal ausprobieren wollen, bauen wir die Implementierung nun um: ein Array soll verwendet werden. Solange die Tests grün bleiben ist Umbauen erlaubt. Wir führen zunächst einen weiteren Konstruktor ein, welcher die initialie Größe des Arrays setzt. Der Default-Konstruktor soll das Array mit einer Größe von 2 Initialisieren:

[TestMethod]
public void testConstructor()
{
     Assert.AreEqual(2, stack.Capacity);
}

Listing 1: Testfunktion für den neuen Konstruktor

Zunächst implementieren wir wieder die Eigenschaft in IStack und MyStack (der Test sollte rot sein), dann lassen wir die Eigenschaft Capacity den Wert 2 zurückgeben.

[TestMethod]
public void testConstructor()
{
     Assert.AreEqual(2, stack.Capacity);
     IStack<String> stack2 = new MyStack<String>(7);
     Assert.AreEqual(7, stack2.Capacity);
}

Listing 2: Zusätzliche Überprüfung des benutzerdefinierten Konstruktors

Dieses Mal muss nur MyStack implementiert werden. Zunächst für Phase 1 ("make it red"):

public MyStack() { }
public MyStack(int initialCapacity) { }

Listing 3: MyStack()

Dann für Phase 2 ("make it green"):

private int capacity;
public MyStack() {
     capacity = 2;
}
public MyStack(int initialCapacity) {
     capacity = 7;
}
public int Capacity
{
     get { return capacity; }
}

Listing 4: Implementierung von MyStack() und Capacity()

Die nächste Iteration initialisiert einen weiteren Stack mit einer Größe ungleich 7 und erzwingt so, dass der Konstruktor keine statische Größe verwendet. Sind wieder alle Tests grün können wir uns langsam Richtung Array vorarbeiten. Die einzelnen Schritte sind dabei wenig interessant, wichtig ist, dass am Ende wieder alles funktioniert. Hier die neue Klasse MyStack:

public class MyStack<T> : IStack<T>
{
      private int size = 0;
      private T[] items;
      public MyStack() {
            items = new T[2];
      }
      public MyStack(int initialCapacity) {
            items = new T[initialCapacity];
      }
      public void Push(T item)
      {
            items[size++] = item;
      }
      public T Pop()
      {
            return items[--size];
      }
      public int Size
      {
            get { return size; }
      }
      public int Capacity
      {
            get { return items.Length; }
      }
}

Listing 5: Klasse MyStack

Voilá, alles grün.

Blackbox- und Whitebox-Testing  

Der geübte Leser hat natürlich sofort gesehen, dass dieser Stack schnell überläuft. Ist das Array voll, wird es nämlich nicht vergrößert. Das Problem ist entstanden, weil wir sogenanntes Blackbox-Testing betrieben haben. Blackbox-Testing betrachtet nicht die dahinterliegende Klasse, sondern prüft nur, ob das Interface korrekt implementiert wurde. Wir testen in diesem Fall "gegen das Interface". Betreibt man Whitebox-Testing, weiß man wesentlich mehr über die dahinterliegende Implementierung. Insbesondere kennt man die Grenzfälle, die getestet werden müssen. Ein solcher Grenzfall ist auch die Situation, dass das Array voll ist. Es gibt kein Patentrezept, wann wie zu testen ist. Die Frage ist immer, was der betrachtete Test eigentlich sicher stellen soll. In vielen Fällen reichen Blackbox-Tests nicht aus.

Der Umgang mit Bugs  

Liefern wir den Stack so aus, wie er jetzt ist, wird der Benutzer früher oder später den zweiten Konstruktor entdecken. initialCapacity klingt so, als würde sie sich noch ändern. Sie tut es aber nicht. Auch die Eigenschaft Capacity ist nur lesbar. Der Stack läuft voll, sofern man am Anfang nicht die benötigte Kapazität kennt. Der Benutzer meldet uns dies als Bug.

Bugs werden im TDD ebenso behoben, wie neue Funktionalität implementiert wird. Ohne nach der konkreten Lösung zu suchen wird das Problem zunächst in einem Test nachgestellt. Am Ende des Tests wird aber der erwartete Wert gefordert:

[TestMethod]
public void testIncreasingCapacity() {
      stack.Push(1);
      stack.Push(2);
      stack.Push(3);
      int expected = 4;
      int actual = stack.Capacity;
      Assert.AreEqual(expected, actual);
}

Listing 6: Test für wachsende Kapazität

Da wir einen Bug haben, ist der Test rot. Der Bug gilt als behoben, wenn der Test grün ist. Wir bauen also unsere Push()-Methode so um, dass die Größe des Arrays verdoppelt wird, sobald es voll ist:

public void Push(T item)
{
      if (size == 2) {
           T[] newItems = new T[4];
           Array.Copy(items, newItems,2);
           items = newItems;
      }
      items[size++] = item;
}

Listing 7: Push() mit Kapazitätserweiterung bei Grösse 2

Der Test bleibt natürlich Teil unserer Testsuite. Zum einen hat er neuen Code erzwungen, der ohne diesem neuen Test nicht getestet werden würde. Zum anderen können wir so sicherstellen, dass wir nicht aus Versehen irgendwann den Bug wieder einbauen. Wir haben hier natürlich wieder die einfachste Lösung gewählt, ein weiterer Test wird sicherstellen, dass die Verdopplung der Kapazität nicht nur bei der Größe 2 erfolgt.

[TestMethod]
public void testIncreasingCapacityThreeTimes()
{
      stack = new MyStack<int>(1);
      int expected = 1;
      int actual ;
      stack.Push(1);
      expected = 1;
      actual = stack.Capacity;
      Assert.AreEqual(expected, actual);
      stack.Push(2);
      expected = 2;
      actual = stack.Capacity;
      Assert.AreEqual(expected, actual);
      stack.Push(3);
      expected = 4;
      actual = stack.Capacity;
      Assert.AreEqual(expected, actual);
}

Listing 8: Testmethode testIncreasingCapacityThreeTimes()

Ergibt beispielsweise:

public void Push(T item)
{
  if (size == Capacity) {
      T[] newItems = new T[Capacity * 2];
      Array.Copy(items, newItems, Capacity);
      items = newItems;
  }
  items[size++] = item;
}

Listing 9: Neue Push()-Methode

Private Methoden und SRP  

Damit das Ganze nicht zu unübersichtlich wird, gliedern wir das verdoppeln der Größe in unserer Methode Push() in eine separate private Methode aus. Dazu wählen wir alle Zeilen bis auf die letzte, verwenden die Tastenkombination Strg+R, Strg+M und geben der neuen Methode einen sinnvollen Namen. Das Ergebnis:

public void Push(T item)
{
      assureFreeCapacity();
      items[size++] = item;
}
private void assureFreeCapacity()
{
      if (size == Capacity)
      {
           T[] newItems = new T[Capacity * 2];
           Array.Copy(items, newItems, Capacity);
           items = newItems;
      }
}

Listing 10: assureFreeCapacity() ausgelagert

Doch wie sind solche Methoden zu testen? Müsste man sie nicht ebenfalls öffentlich machen, damit man sie testen kann? Zunächst einmal: wenn man sie testen wollte, müsste man sie tatsächlich öffentlich machen. Wir brauchen sie aber gar nicht zu testen. Der Grund hierfür ist, dass die Methode indirekt durch die Methode Push() getestet wird. Dies bedeutet allerdings auch, dass wir, obwohl die Methode Push() kürzer geworden ist, keine Tests für sie löschen dürfen. Die Tests für Push() werden weiterhin alle gebraucht.

Dies führt zugegeben schnell dazu, dass für einzelne Methoden sehr viele Tests existieren, die zahlreiche interne Methoden testen. Hier gibt es ein Prinzip, welches in der Objektorientierung sehr wichtig, wenn auch häufig nicht gelebt ist. Das S ingle R esponsibility P rinciple, kurz SRP. Es besagt, dass eine Klasse nur genau eine Verantwortlichkeit haben darf. Verstecken sich viele private Methoden in einer Klasse, ist dies häufig ein Zeichen, dass sie intern eigentlich viel mehr tut. Stellen wir uns eine Kontakt-Klasse vor, welche auch das Foto des jeweiligen Kontakts beinhaltet. Erstreckt sich das Laden des Bildes über mehrere Methoden, weil erst Pfadnamen gebildet, dann Bilder konvertiert, und zuletzt das Bild angezeigt werden muss, ist das ein Verstoß gegen das SRP. In diesem Fall sollte die Funktionalität in eine Picture-Klasse und eine Pfad-Klasse ausgelagert werden. Die ursprünglichen privaten Methoden sind dann öffentliche Methoden der neuen Klasse und können getestet werden. Als Nebeneffekt erhalten wir ein sehr schönes Programmdesign.

Die Tests der Kontakt-Klasse müssen dann lediglich erzwingen, dass in den neuen Klassen die entsprechenden Methoden aufgerufen werden. Wie dies geht, wird weiter unten behandelt ("Komplexe Klassen Testen").

Komplexere Tests  

Eigentlich sollten Tests nicht komplex sein. Sie sollen insbesondere möglichst fehlerfrei sein. Deshalb enthalten Tests eigentlich keine Logik. Außerdem müssen sie reproduzierbar sein. Arbeitet ein Test mit Zufallszahlen und schlägt manchmal fehl, manchmal aber auch nicht, hilft dies nicht beim Debuggen.

Es gibt aber ein paar Punkte, die ich unter dieser Überschrift kurz ansprechen möchte:

Exceptions testens

Möchte man sicherstellen, dass eine Methode eine Exception wirft, muss man im klassischen Ansatz einen kleinen Umweg laufen:

[TestMethod]
public void testExceptionOnEmptyStack()
{
       try
       {
             stack.Pop();
             Assert.Fail();
       }
       catch (StackIsEmptyException e)
       {
       }
}

Listing 11: testExceptionOnEmptyStack()

Der Ansatz ist flexibel, das .NET Framework stellt allerdings eine kürzere Möglichkeit zur Verfügung, welche etwas weniger flexibel ist, in 90% der Fälle aber ausreicht:

[TestMethod, ExpectedException(typeof(StackIsEmptyException))]
public void testExceptionOnEmptyStack()
{
             stack.Pop();
}

Listing 12: TestMethod mit Exception-Unterstützung

ToDo-Listen

Ein weiteres Szenario, welchem man häufig begegnet, ist die Problematik, dass man während einem Test merkt, dass die Implementierung komplexer wird als gedacht. Ein Beispiel ist hier die häufig zitierte Bruch-Klasse. Wir gehen davon aus, dass eine Klasse eine rationale Zahl in Form eines Bruches darstellt. Es sind bereits Nenner und Zähler implementiert. Nun soll die Addition zweier Brüche erfolgen. Wir stellen jedoch sehr schnell fest, dass wir dafür Brüche erweitern und kürzen können müssen. Für letzteres müssen wir den größten gemeinsamen Teiler von zwei Zahlen ermitteln können. Dies ist für einen Test viel zu viel. An dieser Stelle greifen wir zu einem klassischen Stück Papier. Wer mag, darf auch Notepad verwenden, aber die meisten schwören hier darauf, selbst herumkritzeln zu dürfen. Wir notieren "ggt", "kuerzen" und "erweitern(int)" oder ähnliches und kommentieren unseren angefangenen Test aus.

Nun wird die ToDo-Liste abgearbeitet. Ist sie leer, kann mit der Addition von zwei Brüchen fortgesetzt werden. Die Idee ist, dass immer nur eine Baustelle offen sein sollte. Das bedeutet, dass immer nur der neu geschriebene Test rot sein darf. Wird es unübersichtlich, haben wir nicht viel gewonnen.

Es ist völlig in Ordnung, abzubrechen und sich von der anderen Seite zu nähern. Test-First bedeutet nicht, Top-Down (Bruch, Addition, Kuerzen, GGT) oder Bottom-Up (GGT, Kuerzen, Addition, Bruch) zu entwickeln. Bei TDD entwickelt man vom Bekannten ins Unbekannte. Dabei ist es legitim, zwischendurch die Richtung zu wechseln.

Sprechende Tests

Schlägt eine Test-Methode fehl, und der Name lautet testOneAndOneIsTwo, lässt sich bereits erahnen, was getestet wird. Lautet der Text des Fehlschlages Assert.AreEqual failed. Expected:<1>. Actual:<2>., sagt dies eigentlich alles.

Tests sollten kurz sein. Sind ihre Namen sprechend gehalten zeigt ein Fehlschlag schnell, wo in etwa das Problem zu suchen ist.

Die Assert-Klasse bietet die Möglichkeit, die einzelnen Assertions mit Kommentaren zu versehen. Dies kann genutzt werden, wenn die normale Ausgabe nicht ausreicht. Häufig wird in diesem Fall allerdings gelästert, warum es sich nicht anders lösen ließ.

Der Code dazu lautet dann beispielsweise: Assert.AreEqual(expected, actual, "One and one should be two");. Die zugehörige Fehlermeldung ist jetzt Assert.AreEqual failed. Expected:<2>. Actual:<1>. One and one should be two

Die Methoden der Assert-Klasse

Die Klasse Assert mit ihren statischen Methoden wurde bereits mehrfach verwendet. Der Vollständigkeit halber sollen hier dennoch kurz die wichtigsten Methoden vorgestellt werden:

AreEqual() testet zwei beliebige Werte auf Gleichheit. Dabei wird == verwendet. Es existieren zahlreiche Überladungen für Strings, Integer, Object, etc. Es ist auch eine generische Methode unter ihnen zu finden, welche der Object-Variante vorzuziehen ist. In der Variante für Float und Double kann zusätzlich eine Genauigkeit angegeben werden, so dass Rundungsfehler ebenfalls abgedeckt werden können. AreNotEqual() arbeitet identisch zu AreEqual(), testet aber das Gegenteil.

AreSame() prüft nicht, ob die Objekte gleich im Sinne des ==-Operators sind, sondern ob es sich tatsächlich um die selbe Instanz handelt. Hierzu wird is verwendet. Analog dazu ist AreNotSame() zu betrachten.

IsNull und IsNotNull prüfen, wie der Name bereits vermuten lässt, ob der übergebene Parameter null ist.

IsTrue und IsFalse bekommen einen booleschen Ausdruck und prüfen diesen auf den jeweiligen Wert. Grundsätzlich ist ein Assert.AreEqual(expected, actual) einem Assert.IsTrue(expected == actual) vorzuziehen, da die erste Variante bessere Fehlermeldungen liefert.

Komplexe Klassen testen  

Dieses Thema kommt zuletzt, obwohl es eines der wichtigsten Themen schlechthin ist. Dennoch war ein wenig Vorarbeit nötig, um hier enden zu können.

Wir schließen unsere Stack-Klasse vorerst ab und widmen uns der Auswertung eines UPN-Ausdrucks. Dabei wollen wir nun eine Klasse entwerfen, welche die einzelnen Operationen implementiert. Dabei soll der Ausdruck 3 3 + zum Aufruf von Number(3); Number(3); Add(); Result(); führen. Wir werden diese zwei Methoden der Klasse UPNCalculator nun implementieren. Die Klasse verwendet intern einen Stack. Die Betonung liegt hier auf einen. Das spricht für unser IStack-Interface. Warum ist das wichtig?

Wir wollen versuchen, den Stack weitestmöglich herauszuhalten. Grundsätzlich sollte eine Klasse so lose wie möglich an andere Klassen gekoppelt sein. Dies hat eine Reihe von Vorteilen. Sie kann z.B. leichter durch eine andere Klasse ersetzt werden und im Idealfall muss wenig geändert werden, wenn sich eine andere Klasse ändert. Ebenso wollen wir, dass unsere Tests unabhängig von anderen Klassen laufen. Funktioniert die MyStack-Klasse nicht mehr, ist das kein Grund, dass unser UPNCalculator bei den Tests ebenfalls als defekt markiert wird - er macht ja vielleicht alles richtig. Hierfür dienen Mocks. Mock-Objekte sind Dummy-Objekte, die prüfen, ob die richtigen Aufrufe erfolgten. Zunächst ist es dafür wichtig, dass UPNCalculator selbst nirgendwo new MyStack ausführt. Dies hätte sowieso den Nachteil, dass wir uns damit viel zu sehr an eine spezifische Implementierung binden. Die Lösung lautet: das Stack-Objekt von außen "injecten", am besten direkt im Konstruktor.

Doch was wir injecten ist keine Instanz von MyStack, sondern eine Instanz von IStack, die eigens für den Test existiert. Diese Instanz ist kein echter Stack sondern prüft nur, ob die erwarteten Methoden aufgerufen werden, und gibt vordefinierte Werte zurück. Wir schreiben diese Klassen aber nicht selbst, sondern lassen sie bequem zur Laufzeit generieren. Wir verwenden dafür das Tool NMock in aktueller (Beta-)Version, welches unter http://www.nmock.org/ bezogen werden kann. Unser Testprojekt erhält eine zusätzliche Referenz auf die Datei "NMock2.dll".

Im Testprojekt legen wir nun eine neue Klasse UPNCalculatorTest an. Ebenso erstellen wir in unserem eigentlichen Projekt wieder ein Interface und eine Implementierung.

namespace TDD_Demo
{
      public interface IUPNCalculator
      {
           void Number(double number);
      }
      public class UPNCalculator : IUPNCalculator
      {
           public UPNCalculator(IStack<double> stack) {
           }
           public void Number(double number) {
           }
      }
}
namespace TestProject
{
      [TestClass()]
      public class UPNCalculatorTest
      {
           /* ... von VS generierter Code ... */
           private Mockery context;
           private IStack<double> mockedStack;
           private IUPNCalculator calculator;
           [TestInitialize()]
           public void MyTestInitialize()
           {
                 context = new Mockery();
                 mockedStack = context.NewMock<IStack<double>>();
                 calculator = new UPNCalculator(mockedStack);
           }
           [TestCleanup()]
           public void assertMocks() {
                 context.VerifyAllExpectationsHaveBeenMet();
           }
           [TestMethod()]
           public void testNumber()
           {
                 Expect.Once.On(mockedStack).Method("Push").With(4.0);
                 calculator.Number(4.0);
           }
      }
}

Listing 13: Testklasse für UPNCalculator

Der Code der Implementierung sollte verständlich sein. Ein paar Worte zum Test: Vor jedem Test legen wir einen Mock-Kontext an, welchen wir verwenden, um neue Mock-Objekte zu erstellen. In diesem Falle eines vom Typ IStack<double>. Am Ende jedes Tests fragen wir den Mock-Kontext noch, ob alle erwarteten Methoden aufgerufen wurden.

testNumber erwartet den Aufruf der Methode Push auf unseren Stack mit dem Parameter 4.0. Der Test schlägt fehlt, und zwar in assertMocks: der Aufruf fehlt. Also ergänzen wir ihn:

public class UPNCalculator : IUPNCalculator
{
      private IStack<double> stack;
      public UPNCalculator(IStack<double> stack) {
           this.stack = stack;
      }
      public void Number(double number) {
           stack.Push(4.0);
      }
}

Listing 14: Ausbau von UPNCalculator

Um die Konstante loszuwerden ergänzen wir einen zweiten Fall. Wichtig ist hier das context.Ordered. Dieses sorgt dafür, dass die Reihenfolge der erwarteten Aufrufe nicht egal ist. Dies ist sonst der Fall.

[TestMethod()]
public void testNumber()
{
      using (context.Ordered)
      {
           Expect.Once.On(mockedStack).Method("Push").With(4.0);
           Expect.Once.On(mockedStack).Method("Push").With(42.3);
      }
      calculator.Number(4.0);
      calculator.Number(42.3);
}

Listing 15: testNumber()

Nun die Methode Add:

[TestMethod()]
public void testAdd()
{
     Expect.Once.On(mockedStack).Method("Pop").Will(Return.Value(7.0));
     Expect.Once.On(mockedStack).Method("Pop").Will(Return.Value(35.0));
     Expect.Once.On(mockedStack).Method("Push").With(42.0);
     calculator.Plus();
}

Listing 16: testAdd()

Die erste Implementierung, die zu einem grünen Test führt:

public void Plus()
{
     stack.Pop();
     stack.Pop();
     stack.Push(42.0);
}

Listing 17: Plus()-Methode von UPNCalculator

Der zweite Test:

[TestMethod()]
public void testAdd()
{
     using (context.Ordered)
     {
           Expect.Once.On(mockedStack).Method("Pop").Will(Return.Value(7.0));
           Expect.Once.On(mockedStack).Method("Pop").Will(Return.Value(35.0));
           Expect.Once.On(mockedStack).Method("Push").With(42.0);
     }
     calculator.Plus();
     using (context.Ordered)
     {
           Expect.Once.On(mockedStack).Method("Pop").Will(Return.Value(-5.0));
           Expect.Once.On(mockedStack).Method("Pop").Will(Return.Value(3.0));
           Expect.Once.On(mockedStack).Method("Push").With(-2.0);
     }
     calculator.Plus();
}

Listing 18: testAdd() mit verschiedenen Überprüfungen von Plus()

Die Implementierung:

public void Plus()
{
     stack.Push(stack.Pop() + stack.Pop());
}

Listing 19: Implementierung von Plus()

Mocks ermöglichen es, Klassen unabhängig voneinander zu testen.

Weiterführende Themen  

Auch nach dem zweiten Teil gibt es noch Zahlreiche Dinge, die ich nicht nennen konnte. Beispielsweise gibt es die Möglichkeit, mittels [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("TestProject")] zu bestimmen, dass das Test-Projekt als internal deklarierte Klassen sehen kann.

Ein weiteres interessantes Tool ist NCover, welches zeigt, welche Zeilen von Tests abgedeckt werden, und welche nicht. Visual Studio 2010 wird noch einmal mit einigen Neuerungen aufwarten, welche vor allem das Erstellen neuer Methoden aus dem Test heraus einfacher machen wird. Derzeit aktuell auf Channel9 ist ein Webcast, in welchem es darum geht, MVC-ASP.NET Anwendungen Test-First zu entwickeln.

Ich verweise an dieser Stelle daher einfach auf Literatur und das Internet.

Zusammenfassung  

Tests sind ein wichtiges Instrument, um Qualität in Software zu erreichen. Beim Test-Driven Development wird dabei der Test vor der eigentlichen Implementierung geschrieben (Test-First). Es ist wichtig, dass Tests einfach sind, und dass die drei Phasen "make it red", "make it green", "make it nice" eingehalten werden. Die einzelnen Iterationen sollten kurz gehalten werden, da stets nach einer einfachen Lösung gesucht wird.

Bugs werden wie normale Anforderungen behoben, indem zunächst ein Test geschrieben wird. Refactoring gehört zum normalen Alltag. Es dürften Tests und Implementierung geändert werden, solange die Tests grün bleiben.

Komplexe Probleme werden mittels ToDo-Listen in kleine Probleme heruntergebrochen. Haben Klassen zu viele Aufgaben, sollten neue Klassen erstellt werden. Komplexe Abhängigkeiten zwischen diesen werden durch Interfaces gelockert, sodass man einzelne Klassen durch andere Implementierungen oder Mock-Objekte ersetzen kann. Mock-Objekte dienen dazu, Klassen getrennt voneinander zu testen.

Ich hoffe, ich konnte einen kleinen Einblick in die Welt des TDDs geben. Viele Dinge müssten viel granulierter betrachtet werden und einige Aussagen sind sicherlich diskussionswürdig. Das Ziel war es aber, einen allgemeinen Einstig zu schaffen. Die Vertiefung ist dem geneigten Leser überlassen.

In diesem Sinne: Happy Testing!

Jochen Wierum

Ihre Meinung  

Falls Sie Fragen zu diesem Tutorial haben oder Ihre Erfahrung mit anderen Nutzern austauschen möchten, dann teilen Sie uns diese bitte in einem der unten vorhandenen Themen oder über einen neuen Beitrag mit. Hierzu können sie einfach einen Beitrag in einem zum Thema passenden Forum anlegen, welcher automatisch mit dieser Seite verknüpft wird.