Komponententests und Test-First-Entwicklung

Veröffentlicht: 19. Mai 2003 | Aktualisiert: 22. Jun 2004
Von Eric Gunnerson

Links zu verwandten Themen
Download
integerlist.exe

* * *

Auf dieser Seite

Suchen von Bugs Suchen von Bugs
Test-First-Entwicklung Test-First-Entwicklung
Ein Beispiel Ein Beispiel
Hinzufügen der "ToString()"-Methode Hinzufügen der "ToString()"-Methode
Aktivieren von "foreach" Aktivieren von "foreach"
Einige Kommentare Einige Kommentare

Wenn Sie zu den beiden Menschen gehören, die tatsächlich auch die Kurzbiografie am Ende meiner Kolumne gelesen haben, werden Sie wissen, dass ich vor Beginn meiner Laufbahn als Program Manager als Test Lead für den C#-Compiler und davor für den C++-Compiler tätig war. Das erklärt natürlich, warum ich äußerst interessiert an der Analyse und (wann immer möglich) der Vermeidung von Bugs bin.

Eine Möglichkeit, die Anzahl von Bugs in Ihrer Software zu reduzieren, besteht darin, ein professionelles Testteam einzusetzen, das alle möglichen aggressiven Tricks anwendet, um die Software funktionsunfähig zu machen. Das Vorhandensein von Testteams bewirkt leider, dass selbst erfahrene Entwickler immer weniger Zeit darauf verwenden, sich um die Stabilität ihres Codes zu kümmern.

Eine Binsenweisheit in der Softwarewelt lautet, dass Entwickler nicht ihren eigenen Code testen sollten. Dem liegt die Annahme zugrunde, dass Entwickler so viel über ihren eigenen Code wissen, dass sie ganz bestimmte Vorstellungen davon haben, wie dieser richtig zu testen ist. Darin steckt durchaus ein Körnchen Wahrheit, aber ein wichtiger Aspekt wird außer Acht gelassen: Wenn Entwickler ihren eigenen Code nicht testen, woher wollen sie dann wissen, dass er richtig funktioniert?

Die einfache Antwort darauf lautet, dass sie es eben nicht wissen. Und Entwickler, die Code schreiben, der nicht oder nur in bestimmten Situationen funktioniert - das nenne ich ein echtes Problem! Anstatt zu überprüfen, ob ihr Code in allen denkbaren Szenarios fehlerfrei funktioniert, testen sie nur einige wenige Fälle.

Suchen von Bugs

Bugs lassen sich in vielen Stadien finden:

  1. Vom Entwickler beim Schreiben des Codes.

  2. Vom Entwickler bei dem Versuch, den Code zum Laufen zu bringen.

  3. Von einem anderen Entwickler oder Tester im Team.

  4. Im Rahmen eines umfassenderen Produkttests.

  5. Von einem Endbenutzer.

Wenn der Bug in Fall 1 gefunden wird, ist seine Behebung ein Kinderspiel. Je weiter wir uns in dieser Liste jedoch nach unten bewegen, desto kostspieliger wird das Auffinden von Bugs. Das Beheben eines Bugs, der von einem Endbenutzer entdeckt wurde, kann das 100- oder sogar 1000fache kosten. Ganz zu schweigen davon, dass sich der Benutzer in der Regel bis zum nächsten Release mit dem Bug abfinden muss.

Im Idealfall werden alle Bugs vom Entwickler beim Schreiben des Codes entdeckt. Dazu müssen Sie Tests entwickeln, die Sie ausführen können, während Sie den Code schreiben. Es gibt eine interessante Methodologie, die genau das ermöglicht.

Test-First-Entwicklung

Bei der Test-First-Entwicklung schreiben Sie die Tests noch bevor Sie den Code schreiben. Wenn alle Ihre Tests erfolgreich verlaufen, wissen Sie, dass Ihr Code richtig funktioniert, und wenn Sie neue Features hinzufügen, bestätigen diese Tests Ihnen fortlaufend, dass Sie den vorhandenen Code nicht beschädigt haben.

Dieses Konzept wurde in den frühen 90ern in der Smalltalk-Welt erfunden, als Kent Beck SmalltalkUnit schrieb. Im Lauf der Jahre wurden Komponententesteinrichtungen für die meisten Umgebungen geschrieben. Eine besonders gute für die .NET Framework-Welt ist als nUnit (in Englisch) bekannt.

Ein Beispiel

Ich schreibe zunächst eine IntegerList-Klasse, um zu erläutern, wie die Test-First-Entwicklung funktioniert. Dies ist eine Variante der ArrayList, in der Ganzzahlen systemeigen gespeichert werden, so dass kein Overhead durch Boxing und Unboxing entsteht.

Mein erster Schritt besteht darin, ein Konsolenprojekt zu erstellen und diesem eine IntegerList.cs-Quelldatei hinzuzufügen. Für eine Verknüpfung mit dem nUnit-Framework muss ich Verweise auf das nUnit-Framework hinzufügen. Auf meinem System befinden sich diese im Ordner d:\program files\nUnit v2.0\bin.

Als Zweites muss ich mir die Zeit nehmen, darüber nachzudenken, wie ich diese Klasse testen werde. Das ähnelt der Entscheidung darüber, welchen Funktionsumfang die Klasse haben soll. Der Schwerpunkt liegt jedoch nicht auf Funktionen (Hinzufügen eines Elements zur Liste), sondern auf bestimmten Verwendungszwecken (Hinzufügen des Wertes 1 zur Liste und anschließendes Überprüfen, ob das Hinzufügen erfolgreich war). Für das Erstellen der Klasse entwerfen wir zunächst eine Liste der Tests, die wir verwenden werden:

  1. Testen, ob sie erstellt werden kann.

  2. Hinzufügen von zwei Ganzzahlen zur Liste und Sicherstellen, dass die Anzahl und die Elemente korrekt sind.

  3. Siehe oben, aber für eine größere Anzahl von Elementen.

  4. Konvertieren der Liste in eine Zeichenfolge.

  5. Enumerieren der Liste unter Verwendung von foreach.


Dieses Beispiel ist nicht wirklich typisch, da ich von Anfang an eine klare Vorstellung von den Funktionen der Klasse habe. Die meisten Klassen werden schrittweise erstellt, und Tests werden in dem Maße hinzugefügt, in dem die Klasse anwächst.

Jetzt kann ich loslegen. Ich erstelle eine neue C#-Klassendatei mit dem Namen IntegerListTest.cs, in der ich alle meine Tests speichere. Nachfolgend sehen Sie die Datei mit dem ersten Test:

using System; 
using System.Collections; 
using NUnit.Framework; 
namespace IntegerList 
{ 
 /// <summary> 
 /// Summary description for IntegerClassTest. 
 /// </summary> 
 [TestFixture] 
 public class IntegerClassTest 
 { 
  [Test] 
  public void ListCreation() 
  { 
   IntegerList list = new IntegerList(); 
   Assertion.AssertNotNull(list); 
  } 
 } 
}

Das [TestFixture]-Attribut markiert diese Klasse als Testklasse, und das [Test]-Attribut markiert die ListCreation()-Methode als Testmethode. In dieser Methode erstelle ich eine Liste und verwende dann die Assertion-Klasse, um zu testen, ob das Objekt erstellt wird.

Ich starte das nUnit-GUI-Testprogramm, öffne meine EXE-Datei und führe die Tests aus. Daraufhin wird folgendes Fenster angezeigt:

csharp03202003-fig01.gif

Abbildung 1. Die "nUnit"-GUI zeigt die Testergebnisse an

Alle meine Tests wurden erfolgreich ausgeführt. Jetzt möchte ich einige echte Funktionen hinzufügen. Zunächst möchte ich der Liste eine Ganzzahl hinzufügen. So sieht der Test aus:

  [Test] 
  public void TestSimpleAdd() 
  { 
   IntegerList list = new IntegerList(); 
   list.Add(5); 
   list.Add(10); 
   Assertion.AssertEquals(2, list.Count); 
   Assertion.AssertEquals(5, list[0]); 
   Assertion.AssertEquals(10, list[1]); 
  }

In diesem Test habe ich zwei Dinge gleichzeitig überprüft:

  • Ob die Liste eine Count-Eigenschaft richtig beibehält

  • Ob sie zwei Elemente enthalten kann


Einige Befürworter der Test-First-Entwicklung plädieren für möglichst granuläre Tests. Da ich es jedoch seltsam fände, die Anzahl zu testen, ohne dabei auch die Elemente zu überprüfen, habe ich mich für meine eigene Lösung entschieden.

Wenn ich diesen Code kompiliere, wird ein Fehler ausgelöst, da die IntegerList-Klasse keine Methoden enthält. Ich füge also Stubs hinzu, um das Kompilieren zu ermöglichen:

  public int Count 
  { 
   get 
   { 
 return -1; 
   } 
  } 
  public void Add(int value) 
  { 
  } 
  public int this[int index] 
  { 
   get 
   { 
 return -1; 
   } 
  }

Anschließend führe ich die Tests erneut aus. Sie werden jetzt rot angezeigt, da mein Test fehlschlägt. Das ist gut, denn es bedeutet, dass meine Tests tatsächlich etwas überprüfen, das zurzeit noch nicht richtig funktioniert. Anschließend kann ich die Implementierung vornehmen. Ich entscheide mich für etwas Einfaches, wenn auch Ineffizientes:

  public int Count 
  { 
   get 
   { 
 return elements.Length; 
   } 
  } 
  public void Add(int value) 
  { 
   int newIndex; 
   if (elements != null) 
   { 
 int[] newElements = new int[elements.Length + 1]; 
 for (int index = 0; index < elements.Length; 
   index++)  
 { 
  newElements[index] = elements[index]; 
 } 
 newIndex = elements.Length; 
 elements = newElements; 
   } 
   else 
   { 
 elements = new int[1]; 
 newIndex = 0; 
   } 
   elements[newIndex] = value; 
  } 
  public int this[int index] 
  { 
   get 
   { 
 return elements[index]; 
   } 
  }

Nun ist ein kleiner Teil meiner Klasse fertig, und ich verfüge über Tests, um sicherzustellen, dass sie richtig funktioniert. Ich habe sie jedoch nur mit einer kleinen Anzahl von Elementen getestet. Als Nächstes schreibe ich einen Test, der eine Überprüfung für 1000 Elemente durchführt:

  [Test] 
  public void TestOneThousandItems() 
  { 
   list = new IntegerList(); 
   for (int i = 0; i < 1000; i++) 
   { 
 list.Add(i); 
   } 
   Assertion.AssertEquals(1000, list.Count); 
   for (int i = 0; i < 1000; i++) 
   { 
 Assertion.AssertEquals(i, list[i]); 
   } 
  }

Da dieser Test fehlerfrei funktioniert hat, muss ich keine Änderungen vornehmen.

Hinzufügen der "ToString()"-Methode

Nun füge ich Code hinzu, um zu testen, ob die ToString()-Methode korrekt ausgeführt wird:

  [Test] 
  public void TestToString() 
  { 
   IntegerList list = new IntegerList(); 
   list.Add(5); 
   list.Add(10); 
   string t = list.ToString(); 
   Assertion.AssertEquals("5, 10", t.ToString()); 
  }

Das schlägt schon mal fehl, sehr schön. Für einen erfolgreichen Testlauf benötigen wir folgenden Code:

  public override string ToString() 
  { 
   string[] items = new string[elements.Length]; 
   for (int index = 0; index < elements.Length; index++) 
   { 
 items[index] = elements[index].ToString(); 
   } 
   return String.Join(", ", items); 
  }

Aktivieren von "foreach"

Viele Benutzer möchten sicherlich meine Liste mit foreach durchlaufen können. Zu diesem Zweck muss ich IEnumerable für die Klasse implementieren und eine separate Klasse definieren, die IEnumerable implementiert. Zunächst der Test:

  [Test] 
  public void TestForeach() 
  { 
   IntegerList list = new IntegerList(); 
   list.Add(5); 
   list.Add(10); 
   list.Add(15); 
   list.Add(20); 
   ArrayList items = new ArrayList(); 
   foreach (int value in list) 
   { 
 items.Add(value); 
   } 
   Assertion.AssertEquals("Count", 4, items.Count); 
   Assertion.AssertEquals("index 0", 5, items[0]); 
   Assertion.AssertEquals("index 1", 10, items[1]); 
   Assertion.AssertEquals("index 2", 15, items[2]); 
   Assertion.AssertEquals("index 3", 20, items[3]); 
  }

Zudem habe ich dafür gesorgt, dass IEnumerable durch IntegerList implementiert wird:

  public IEnumerator GetEnumerator() 
  { 
   return null; 
  }

Dadurch wird beim Ausführen der Tests eine Ausnahme generiert. Für eine korrekte Implementierung verwende ich eine verschachtelte Klasse für den Enumerator.

 class IntegerListEnumerator: IEnumerator 
 { 
  IntegerList list; 
  int index = -1; 
  public IntegerListEnumerator(IntegerList list) 
  { 
   this.list = list; 
  } 
  public bool MoveNext() 
  { 
   index++; 
   if (index == list.Count) 
 return(false); 
   else 
 return(true); 
  } 
  public object Current 
  { 
   get 
   { 
 return(list[index]); 
   } 
  } 
  public void Reset() 
  { 
   index = -1; 
  } 
 }

Ein Zeiger auf das IntegerList-Objekt wird an diese Klasse übergeben, die daraufhin lediglich Elemente dafür zurückgibt.

Dies macht die Liste foreach-fähig. Doch leider ist die Current-Eigenschaft als Objekt typisiert, so dass jeder Wert für seine Rückgabe verpackt wird. Dieses Problem kann durch einen musterbasierten Ansatz gelöst werden, der genauso aussieht wie der aktuelle Ansatz, in dem GetEnumerator() jedoch eine reale Klasse (anstelle von IEnumerator) zurückgibt und die Current-Eigenschaft dieser Klasse vom Typ int ist.

Im Anschluss möchte ich jedoch sicherstellen, dass ich weiterhin den oberflächenbasierten Ansatz für diejenigen Sprachen verwenden kann, die das Muster nicht unterstützen. Ich dupliziere einfach den zuletzt geschriebenen Test und ändere foreach entsprechend für die Oberfläche ab:

 foreach (int value in (IEnumerable) list)

Einige kleine Änderungen, und schon funktioniert die Liste in beiden Fällen. Weitere Informationen und Tests finden Sie im Beispielcode.

Einige Kommentare

Das Schreiben des Codes und des Textes für den Artikel hat mich diesen Monat nur eine Stunde gekostet. Das Schöne daran, die Tests im Voraus zu schreiben, ist, dass Sie eine ganz klare Vorstellung davon haben, was Sie der Klasse hinzufügen müssen, damit die Tests erfolgreich verlaufen. Das Schreiben des Codes gestaltet sich also viel einfacher.

Dieser Ansatz funktioniert am besten mit kleinen inkrementellen Tests. Sie sollten ihn zunächst an einem kleinen Projekt ausprobieren. Die Test-First-Entwicklung ist Bestandteil der so genannten agilen Methoden. Weitere Informationen zur Entwicklung mit agilen Methoden finden Sie unter http://www.agilealliance.com/ (in Englisch).

Ein Blick in die Zukunft
Ich habe ein wenig mit Microsoft DirectX® 9 herumgespielt, wundern Sie sich also nicht, wenn das mein nächstes Thema wird.


Anzeigen: