Die Community zu .NET und Classic VB.
Menü

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

 von 

Einleitung 

Visual Studio 2008 besitzt ab der Professional Edition die Möglichkeit, Unit-Tests zu schreiben. NUnit ermöglicht es, auch in den Express-Editionen Tests zu schreiben. In der Industrie wird Test Driven Development (TDD) zunehmend wichtiger. Auch im Studium wird es mittlerweile häufig gelehrt.

Grund genug, sich mit dem Thema einmal näher auseinanderzusetzen. Dieser Text stellt TDD ein wenig vor. Dabei wird in den Beispielen VS2008 Professional verwendet. Die Ideen, welche NUnit verfolgt sind aber die gleichen, sodass nur das Schreiben des Tests variiert. Hier möchte ich auf die NUnit Dokumentation verweisen.

Test First  

Eigentlich sagt "Test First", beziehungsweise "Teste zuerst" schon recht viel, aber worum geht es hier genau?

Schreibt man Software, so hat man irgendeine Art Spezifikation vor sich. Sei es die Beschreibung eines Algorithmus oder eine detaillierte Wunschliste des Kunden. Im "klassischen" Ansatz wird diese Liste Punkt für Punkt umgesetzt. Dabei entsteht eine Vielzahl von Klassen. Da nicht immer ersichtlich ist, wie diese funktionieren, wird meist auch eine Dokumentation benötigt. Eine einzelne Klasse ist oft von vielen anderen Klassen abhängig, sodass es schwierig ist, sie herauszulösen. Ein Horror-Szenario ist oft, wenn die Software drei Jahre lang stabil läuft und dann etwas geändert werden soll. Wer hat den Code damals geschrieben? Reicht es nicht einfach, Zeile X zu ändern? Tatsächlich, es funktioniert... Aber habe ich damit jetzt etwas anderes kaputt gemacht?

Test Driven Development soll dies alles besser machen. Es wird ein Zwischenschritt eingefügt: Es wird nicht gleich die Implementierung geschrieben, sondern zunächst ein Test, der den noch nicht vorhandenen Code aufruft und dessen Rückgabe überprüft. Dann wird der fehlende Code implementiert. Und zwar nicht so, wie er in der Spezifikation gefordert wird, sondern so, dass er geradeeben den Test erfüllt. Tut er es, wird der nächste Test geschrieben. Ein einzelner Test verlangt dabei nicht besonders viel. Man findet immer wieder den Begriff "Little Baby Steps". Wir werden gleich einen Stack Test First entwickeln. Dabei wird ersichtlich, wie klein diese Schritte wirklich sind. Wichtig ist, dass die einzelnen Iterationen schnell gehen. Es soll nicht übermäßig viel Zeit beim Schreiben des Tests oder beim Schreiben der Implementierung verloren gehen. Vielmehr sollen viele Tests geschrieben werden. Jede Klasse wird, soweit es möglich ist, für sich entwickelt. Es ergeben sich eine ganze Reihe Vorteile aus dem Verfahren:

  • Die Tests dokumentieren, wie eine Klasse zu verstehen und zu verwenden ist. Ein Teil der Dokumentation entfällt somit.
  • Wird Code verändert, kann jederzeit geprüft werden, ob die Änderung die bestehende Funktionalität zerstört. Dies ist ganz einfach möglich, indem die Tests nach der Änderung erneut ausgeführt werden.
  • Dadurch, dass Klassen möglichst isoliert entwickelt werden, ist es möglich, sie leichter durch andere zu ersetzen. Dieser Punkt wird in der Fortsetzung näher behandelt.
  • Lange Debugging-Sessions entfallen größtenteils, da durch die Tests Fehler schnell eingegrenzt werden können. Spätestens hier gewinnt man die Zeit, die man zum Schreiben der Tests investiert hat, zurück.

Es ist wichtig, die Tests vor der Implementierung zu schreiben. So wird sichergestellt, dass die Testabdeckung möglichst hoch ist, d.h. dass möglichst alles von dem geschriebenen Code auch von irgendeinem Test geprüft wird. Schreibt man die Tests danach, so "vergisst" man gerne einmal etwas.

Ein wichtiger Punkt ist hier noch zu nennen: Testen und Verifizieren sind verschiedene Dinge. Tests beweisen nicht, dass das Programm exakt der Spezifikation entspricht. Sie weisen lediglich nach, dass einige ausgewählte Fälle funktionieren. Die Fälle sollte man sinnvoll wählen, also z.B. nach Möglichkeit nah an den Grenzen testen.

Rot und grün: Unittests  

Unittests sind Tests, die auf der kleinstmöglichen Basis arbeiten. Während Integrationstests prüfen, ob sich die Anwendung als Ganzes in ihre Umgebung "integriert", prüfen Unittests, ob die einzelnen Klassen das tun, was sie sollen. Unittests können und sollen jederzeit (in VS mit Strg+R, A) ausgeführt werden. Wichtig ist, dass die Tests schnell laufen. Sie sollen so regelmäßig wie möglich ausgeführt werden, am besten nach jedem Speichern. Daher dürfen sie nie länger als einige Sekunden laufen. Dies führt dazu, dass die Tests keine Zugriffe auf Dateien etc. machen dürfen, weil dies zu viel Zeit kostet. Wie man dies umgehen kann wird in einer Fortsetzung angesprochen.

Tests haben zwei Zustände: sie laufen Fehlerfrei, oder sie tun es nicht. Im ersten Fall spricht man davon, dass ein Test "grün" ist. In diversen IDEs werden farbliche Balken für den Fortschritt der Tests verwendet, wodurch sich diese Bezeichnung ergibt. Ist ein Test "rot", schlug er fehl. In Visual Studio öffnet sich das angedockte Fenster "Test Result", wenn man die Tests startet. Hier genügt ein Doppelklick auf den fehlgeschlagenen Test um herauszufinden, warum er fehl schlug. Ich empfehle, es einfach einmal auszuprobieren. Die Fehlermeldung lautet beispielsweise "Assert.AreEqual failed. Expected:<1>. Actual:<2>."

Ein Test besteht aus dem Aufbau eines Szenarios und mindestens einer Zusicherung ("assertion"). In der Zusicherung wird überprüft, ob das vorherige Szenario das gewünschte Ergebnis lieferte. Schlägt eine Assertion fehl, wird der komplette Test abgebrochen und der nächste Test wird gestartet.

Dies bedeutet, das Tests nicht voneinander abhängen dürfen, sonst würden alle Folgetests ebenfalls fehlschlagen. Die Reihenfolge der Tests ist ebenfalls nicht vorhersagbar. Jeder Test sollte daher bei null anfangen. Es gibt allerdings die Möglichkeit eine Setup-Methode zu registrieren, die vor jedem Test ausgeführt wird. Damit ist es möglich, einen definierten Anfangszustand zu schaffen.

Wir schauen uns nun ein praktisches Beispiel an. Dabei werden wir stets kurz rekapitulieren, warum wir etwas tun. In der Fortsetzung werden dann einige der Techniken im Detail beleuchtet.

Das Projekt  

Wir werden im Laufe des Tutorials einen kleinen Rechner für die Umgekehrt Polnische Notation schreiben. Dieser löst Rechenausdrücke der Form "3 5 + 6 *" auf und berechnet sie ("(3 + 5) * 6"). Eine Beschreibung der UPN befindet sich bereits in unseren Tutorials.

Zunächst werden wir einen Stack selbst implementieren. Einfache Klassen wie Stacks, Queues, Brüche oder Komplexe Zahlen eignen sich besonders gut, um erste Tests zu schreiben.

Wir legen zunächst ein einfaches Konsolenprojekt ("TDD-Demo") an. Im Solution Explorer machen wir einen Rechtsklick auf die Projektmappe und wählen "Add > New Project". Im aufpoppenden Dialog wählen wir "Visual C# > Test > Test Project". Im neuen Projekt fügen wir nun mittels Rechtsklick auf das Projekt und der Wahl von "Add Reference" eine Referenz auf unser eigentliches Projekt ein.

Es geht los  

Wir beginnen mit unserem Test. Hierzu editieren wir die bereits angelegte Testklasse. Eine Testklasse testet immer genau eine reale Klasse. Die Testklasse sollte - verkürzt - wie folgt aussehen:

using TDD_Demo;
namespace TestProject
{
      [TestClass]
      public class StackTest
      {
           private IStack<int> stack;
           private TestContext testContextInstance;
           public TestContext TestContext
           {
                get { return testContextInstance; }
                set { testContextInstance = value; }
           }
           [TestInitialize()]
           public void MyTestInitialize() {
                stack = new MyStack<int>();
           }
           //...
      }
}

Listing 1: TestProject-Standardcode

Methoden, welche mit [TestInitialize()] gekennzeichnet werden, werden vor jedem Test ausgeführt. Da die Reihenfolge von Tests nicht vorgegeben ist, dürfen sich die Tests nicht gegenseitig manipulieren. Jeder Test soll die identische Umgebung vorfinden. Aus diesem Grund wird das Stack-Objekt jedes Mal neu initialisiert. Wir verwenden hier die Klassen MyStack und IStack. Zunächst legen wir die beiden also in unserem eigentlichen Projekt (TDD_Test) an und lassen MyStack von IStack erben:

namespace TDD_Demo
{
      public interface IStack<T>
      {
      }
}
namespace TDD_Demo
{
      public class MyStack<T> : IStack<T>
      {
      }
}

Listing 2: Grundgerüst des Stacks

Zeit für unseren ersten Test, den wir gleich unter MyTestInitialize erstellen:

[TestMethod]
public void testSize()
{
      stack.Push(1);
      int expected = 1;
      int actual = stack.Size;
      Assert.AreEqual(expected, actual);
}

Listing 3: Funktion testSize()

Zunächst gilt es, erst einmal die Compilerfehler zu beseitigen. Dafür erstellen wir im Interface eine Methode void Push(T item); und die Property int Size { get; }. Die Implementierung in MyStack sollte dabei zunächst so einfach wie möglich sein:

public void Push(T item)
{
}
public int Size
{
      get { return 0; }
}

Listing 4: Basisfunktionalität des Stacks

Phase 1: make it red

Wenn wir den Test nun ausführen, schlägt er fehl. Dieser Schritt ist zwingend. Warum? Zum Einen ist es eine gute Prüfung, ob der Test überhaupt ausgeführt wird. Zum anderen soll jeder Test etwas neues in die Implementierung bringen. Ist der Test also bereits jetzt grün, müssen wir überlegen, warum dies der Fall ist. Schreiben wir gerade wirklich etwas, was es noch nicht gibt? Es funktioniert doch bereits...

Meistens ist ein Test schon rot, sobald man die nötigen Methoden erstellt hat. Sie machen ja noch nichts.

Phase 2: make it green

Jetzt geht es darum, den Test grün zu bekommen. Dieser Schritt ist gerade am Anfang schwierig, weil die für das endgültige Ziel naheliegendste Lösung nicht immer die gewünschte ist. Es wird stets die einfachste Lösung gesucht. Diese lautet in unserem Fall:

public int Size
{
      get { return 1; }
}

Listing 5: Funktion Size() zum Ersten

Warum geben wir uns damit zufrieden? Die Antwort ist leicht: wir wollen eine hohe Testabdeckung. Würden wir hier mehr implementieren, als nötig, wäre dies nicht getestet. Damit wäre es später jedoch schwierig, Fehler zu finden.

Phase 3: make it nice

Im Normalfall wäre jetzt ein Refactoring fällig. Es ist allerdings noch nicht genug passiert, als dass wir hier etwas refactorn könnten. Wir kommen in ein paar Zyklen wieder auf diesen Schritt zurück.

Der zweite Test  

Da alles grün ist, kann unser Code alles, was wir von ihm erwarten. Also schrauben wir unsere Erwartungen höher:

[TestMethod]
public void testSizeWithTwoPushes()
{
      stack.Push(1);
      stack.Push(7);
      int expected = 2;
      int actual = stack.Size;
      Assert.AreEqual(expected, actual);
}

Listing 6: Erweiterter Test von Size()

Der Test ist nach dem Kompilieren sofort rot, ohne dass wir neue Methoden schreiben müssen. Schritt 1 ist erreicht. Wie bekommen wir ihn am einfachsten grün?

int size = 0;
public void Push(T item)
{
      ++size;
}
public int Size
{
      get { return size; }
}

Listing 7: Funktionen Push() und Size() zum Zweiten

Noch einmal: Es ist eine Herausforderung, hier nicht zu viel zu tun. Dadurch, das ein solcher Zyklus aber nur Sekunden bis hin zu wenigen Minuten dauert, lässt es sich mit ein wenig Übung verkraften. Es ergibt immer noch keinen Sinn, zu refactorn.

Wann schreibe ich einen Test?  

Grundsätzlich ist es sinnvoll, pro Methode einen oder mehrere Tests zu schreiben. Es ist auch legitim, mehr als ein Assert pro Test zu verwenden. Grundsätzlich gilt die Faustregel: pro Test sollte nicht mehr als eine Funktionalität getestet werden. Die Methode Size hat ohne die Methode Push() keinen Sinn, wir testen sie daher gemeinsam. Es wäre allerdings wenig hilfreich, in dem gleichen Test auch die Pop()-Methode zu prüfen. Aber auch Pop() lässt sich nur zusammen mit Push() testen. Zunächst wieder der Test:

[TestMethod]
public void testPushPop()
{
      stack.Push(42);
      int expected = 42;
      int actual = stack.Pop();
      Assert.AreEqual(expected, actual);
}

Listing 8: Test von Push() und Pop()

Erstmal müssen wir den Test wieder rot bekommen. Dazu legen wir wieder unsere Methode im Interface an: T Pop();. Und eine minimale Implementierung in MyStack:

public T Pop()
{
      return default(T);
}

Listing 9: Funktion Pop()

Voilá, der Test ist rot, er funktioniert also. Wir bekommen wir ihn grün? Ganz einfach, wir speichern uns bei Push() den Wert in einer Variablen vom Typ T und geben den Wert bei Pop() zurück. Warum geben wir nicht einfach 42 zurück? Das liegt daran, dass wir Generics verwenden und sich 42 nicht zu einem Objekt des Typs T umwandeln lässt. Ansonsten wäre diese Lösung natürlich zu bevorzugen. Wir haben Push() verändert. Deshalb lassen wir alle Tests laufen um zu kontrollieren, dass wir Size nicht zufällig beschädigt haben.

Fertigstellung  

[TestMethod]
public void testPushPopTwoTimes()
{
      stack.Push(16);
      stack.Push(32);
      int expected = 32;
      int actual = stack.Pop();
      Assert.AreEqual(expected, actual);
      expected = 16;
      actual = stack.Pop();
      Assert.AreEqual(expected, actual);
}

Listing 10: Erweiterter Test von Push() und Pop()

Der Test ist sofort rot. Wir kommen nun an eine Interessante Stelle. Um den Test grün zu bekommen, könnten wir in pop mitzählen, wie oft es aufgerufen wurde und davon abhängig unterschiedlich reagieren. Nur wäre diese Lösung nicht leichter als jene, die wir eigentlich anstreben. Also implementieren wir diese auch. Es geht nicht darum, die Tests hinters Licht zu führen. Eine konkrete Implementierung zu erzwingen und deren Richtigkeit zu beweisen geht zu weit und ist in vielen Fällen nicht ohne weiteres möglich. Das Ziel der Tests liegt vielmehr darin, uns abzusichern, nicht darin, Funktionalität zu erzwingen. Wir verwenden im ersten Wurf eine LinkedList:

private LinkedList<T> items = new LinkedList<T>();
public void Push(T item)
{
      ++size;
      items.AddLast(item);
}
public T Pop()
{
      T result = items.Last.Value;
      items.RemoveLast();
      return result;
}

Listing 11: Push() und Pop() mit weiterer Funktionalität

Ein Test für drei Elemente würde uns nicht weiterbringen, er wäre sofort grün. Also begeben wir uns noch einmal zurück zu Size:

[TestMethod]
public void testSizeWithTwoPushesAndPop()
{
      stack.Push(1);
      stack.Push(7);
      stack.Pop();
      int expected = 1;
      int actual = stack.Size;
      Assert.AreEqual(expected, actual);
}

Listing 12: Test von Size() mit zwei Mal Push() und einmal Pop()

Die Fehlermeldung verrät uns: 2 ist nicht 1. Wir haben bei Pop() die Variable size nicht dekrementiert. Und das mit Absicht! Es war bisher nirgendwo gefordert. Dies können wir nun tun. Der Test ist grün. Jetzt ist die Zeit gekommen, noch einmal über die 3. Phase ("make it nice") nachzudenken. Solange unsere Tests grün sind, dürfen wir nämlich refactorn. Sind sie es nicht, sollte man zunächst alle Probleme beheben. Die Variable size beinhaltet nichts, was unsere LinkedList nicht auch weiß. Also werfen wir die Variable über Bord, löschen die Zeilen ++size; und --size; in Push() und Pop() und ersetzen den getter von Size durch:

public int Size
{
      get { return items.Count; }
}

Listing 13: Size() auf Basis von LinkedList.Count

Bleiben die Tests grün, haben wir alles richtig gemacht. Dieser Schritt und die damit verbundene Erkenntnis ist wichtig: Es ist nicht immer möglich, im ersten Versuch alles perfekt zu machen. Also bauen wir uns ein Gerüst, welches uns vorgibt, wie unser Programm funktionieren soll. Solange es wie erwartet funktioniert, dürfen wir in seinem Inneren beliebig aufräumen und optimieren. So kommen wir nach einigen Iterationen zu sehr sauberem Code.

Zusammenfassung  

Obwohl wir gerade in der Mitte des Themas sind, brechen wir hier ab. Es ist viel wichtiger, das Gelernte zu rekapitulieren, als sich in weitere Abenteuer zu stürzen. Im zweiten Teil werde ich vorstellen, wie man Exceptions testet, wie man Bugs behebt, und wie man die Interaktion mit anderen Objekten simulieren kann, damit ein Test möglichst abgekapselt läuft. Ebenfalls wichtig ist die Todo-Liste: Es darf immer nur ein Test rot sein. Wird das Ganze zu komplex, schreibt man es auf die Todo-Liste und beginnt, das Problem von der anderen Seite zu lösen. Immer vom Bekannten ins Unbekannte.

Wir werden zunächst die Stack-Klasse erweitern. Statt der LinkedList könnte man beispielsweise auf einem Array aufbauen. An dieser Stelle werden wir kurz diskutieren, wie man private Methoden testet und was es mit dem Single-Responsibility-Principle auf sich hat.

Bis dahin empfehle ich, ein wenig mit einfachen Klassen zu spielen. Denkbar wäre es, eine Queue zu implementieren, oder einen Tokenizer, der einen UPN-Ausdruck in kurze Strings zerlegt.

Happy Testing!

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.