Artikel

Oktober 2002 | Artikel

Ich habe Tests, also kann ich!

(Link zum Artikel: http://www.it-republik.de/dotnet/artikel/0252)

Das Open Source Testing-Framework NUnit

Text: von Armin Röhrl und Stefan Schmiedl
  • Teilen
  • kommentieren
  • empfehlen
  • Bookmark and Share
Ein Programmierer, der sein Geld wert ist, schätzt automatische Tests. Nicht nur Akzeptanz-Tests, sondern auch Unit-Tests für jede noch so kleine Methode. Ich habe Tests, also kann ich: Code refaktorieren, tunen und erweitern. Wenn etwas schief geht, zeigen mir die Tests genau an, wo welcher Defekt aufgetreten ist. In diesem Artikel stellen wir vor, wie man Tests für C#-Programme automatisieren kann.

Software-Bugs sind allgegenwärtig: Von der Ariane-Explosion (1996) über den Mars Climate Orbiter (1999, 125 Millionen US-Dollar) bis zu den 63.000 Bugs in den 10 Millionen Codezeilen von Windows 2000. Im Jahre 1999 wurde bekannt, dass 1983 ein Bug in einem Programm, das falsche Raketenalarme auf Grund von Wolkenreflektionen filtern sollte, fast einen Nuklearkrieg ausgelöst hätte. Intel gab an, es seien nicht mehr als 80 bis 90 Fehler im Pentium-Prozessor. Durchschnittliche Programme besitzen 25 Fehler und gute zwei pro 1000 Zeilen. Kritische Systeme wie das Space Shuttle besitzen weniger als einen Fehler pro 10000 Zeilen [Bugs].
Um die Qualität ihrer Software zu verbessern, sollten Programmierer schon während der Entwicklungsphase mit einem Testing Framework ihren Code ständig überprüfen. Dagegen wird oft eingewendet: Tests schreiben dauert zu lange, ich komme nicht weiter. Berücksichtigt man allerdings, dass bei konsequenter und ständiger Kontrolle des Codes die Fehler sofort nach ihrer Entstehung gefunden werden, sieht die Sache schon wieder anders aus: Nächtelange Debug-Sessions fallen weg.

Test-First-Programmierung und Unit-Tests
Wir unterscheiden zwei Arten von Tests: lokale Unit-Tests, die eine einzelne Klasse oder Methode kontrollieren, und globale Akzeptanz-Tests, mit denen ein von außen sichtbares Verhalten überprüft wird. Letztere werden durch den Nutzer des Systems vorgegeben, erstere sollten vom Programmierer bzw. seinem Partner selbst erstellt werden, am besten, bevor der zugehörige Code geschrieben wird. So ist sichergestellt, dass die Tests auch vorhanden sind, wenn sie gebraucht werden: um dem Programmierer zu sagen, wann das aktuelle Problem gelöst ist.
Außerdem entsteht das Sicherheitsnetz der Unit-Tests nur dann, wenn wirklich jede benötigte Funktion durch mindestens einen Test kontrolliert wird, denn wenn sich erst einmal ungetestete Funktionen im Code eingenistet haben, spiegeln die Tests nur noch eine trügerische Sicherheit wider.
Die Implementierung einer neuen Funktion läuft idealerweise so ab:
  • Zwei Programmierer, A und B, holen sich den aktuellen Stand des Systems aus ihrem Versionsmanagement (CVS). Sie lassen die Testsuite laufen und stellen fest, dass alles im grünen Bereich ist.
  • A und B entwerfen und implementieren einen Test für die neue Funktion und lassen die Testsuite laufen, die prompt Fehler anzeigt. Aber jetzt wissen die beiden, dass der Test auch wirklich aufgerufen wird. Je nach Sprache muss hierfür auch die neue Funktion zumindest dem Namen nach schon existieren.
  • Nun wird die neue Funktion so weit aufgebaut, dass genau dieser Testfall korrekt bearbeitet werden kann.
  • So entstehen abwechselnd immer mehr Tests und Produktionscode, bis alle externen Anforderungen an die Funktion erfüllt sind. Wenn dafür Änderungen an vorhandenen Programmteilen notwendig sind, werden sich einschleichende Fehler beim nächsten Testlauf, also jetzt gleich, von den Unit-Tests entdeckt. Da die Änderungen noch frisch im Gedächtnis sind, können die Fehler schnell behoben werden.
  • Sind alle Tests im grünen Bereich und die angeforderten Fähigkeiten vollständig implementiert, werden die Quellcodes in das Versionsmanagement zurückgestellt und A und B widmen sich neuen Aufgaben.
Um die internen Abläufe zu vereinfachen, werden die Tests dabei oft von der realen Umgebung abgekoppelt. Soll zum Beispiel aus dem Programm heraus eine eMail verschickt werden, kann man mit einem kleinen
(Perl-)Skript einen SMTP-Server simulieren [SMTP], der das Testsystem von einer echten Netzwerkverbindung und deren Unwägbarkeiten unabhängig macht. Ebenso können Anbindungen an Datenbanken oder das Internet simuliert werden, wenn sie nicht eigentlicher Gegenstand des Tests sind.
NUnit: Installation und Test
JUnit [JUnit] ist ein beliebtes Regression-Testing-Framework, das von Erich Gamma und Kent Beck entwickelt wurde und ein Open-Source-Projekt unter der IBM Public License ist. NUnit [NUnit] ist das .NET-Gegenstück von Philip Craig und vielen Helfern. Die Lizenz ist noch freier als die von IBM, aber da NUnit Dateien von JUnit benutzt, wird man sich wohl auf die Bedingungen der IBM-Lizenz einschränken müssen. Während diese Zeilen geschrieben werden, wird auf der XP-Mailingliste gerade NUnit 2.0 Beta 2 angekündigt.
Nach dem Download von [NUnit] bekommt man eine ausführliche Anleitung im PDF-Format sowie eine Zip-Datei mit einem Installationspaket für den Windows-Installationsdienst. Nach dem Einspielen gibt es im Startmenü einen Ordner namens Nunit. Wir starten darin Nunit-gui und wählen File | nunit.tests.dll aus. Alle 132 Testfälle müssen erfolgreich ablaufen. Als Alternative kann man mit nunit-console.exe die Tests auch ohne GUI laufen lassen. Nunit-console.exe kann auch XML-Ouput zur Weiterverwendung in anderen Programmen erzeugen.
Beispiele
Am schnellsten wird man NUnit beherrschen lernen, wenn man die Beispiele studiert:
  • C#: drei "failing" Unit-Tests in C# geschrieben
  • VB.NET: drei failing Unit-Tests in VB.NET geschrieben. Das Beispiel zeigt, dass VB.NET keine Exception bei einer Division durch 0 abgibt, auch wenn es laut Anleitung eine Exception werfen sollte.
  • Managed C++: die gleichen drei Tests. Das Verhalten im Debug Mode unterscheidet sich vom Verhalten im Release Mode.
  • Visual J#: die gleichen drei Tests in J#.
  • Money: das bekannte Geld-Beispiel von Kent Beck [TestInfected]. Wir werden den Code gleich aufgreifen.
  • Money-port: das Beispiel zeigt, welche Veränderungen notwendig sind, wenn man mit ältereren NUnit-Versionen gearbeitet hat.
Folgen wir der Datei Quickstart, die mit NUnit ausgeliefert wird. Gehen wir von einer einfachen Klasse Account aus. Ein Account ist ein Bankkonto, von dem man abheben (withdraw), einzahlen (deposit) und auf das man überweisen kann. Die Account-Klasse könnte aussehen wie in Listing 1 gezeigt.

Listing 1
  1. namespace bank
  2. {
  3. public class Account
  4. {
  5. private float balance;
  6. public void Deposit(float amount)
  7. {
  8. balance+=amount;
  9. }
  10. public void Withdraw(float amount)
  11. {
  12. balance-=amount;
  13. }
  14. public void TransferFunds(Account destination, float amount)
  15. {
  16. }
  17. public float Balance
  18. {
  19. get{ return balance; }
  20. }
  21. }
  22. }
Test-Methoden
Nun schreiben wir dafür einen Test AccountTest. Die erste Methode, die wir testen werden, ist TransferFunds (siehe Listing 2). Durch das vererbbare Attribut [TestFixture] wird gezeigt, dass die Klasse Testcode enthält. Die Klasse selbst muss public sein und einen Default-Konstruktor haben; bezüglich der Superklasse gibt es keine Einschränkungen.

Listing 2
  1. namespace bank
  2. {
  3. using Nunit.Framework;
  4. [TestFixture]
  5. public class AccountTest
  6. {
  7. [Test]
  8. public void TransferFunds()
  9. {
  10. Account source = new Account();
  11. source.Deposit(200.00F);
  12. Account destination = new Account();
  13. destination.Deposit(150.00F);
  14. source.TransferFunds(destination, 100.00F);
  15. Assertion.AssertEquals(250.00F, destination.Balance);
  16. Assertion.AssertEquals(100.00F, source.Balance);
  17. }
  18. }
  19. }
Test-Methoden wie TransferFunds werden durch das [Test]-Attribut gekennzeichnet, sind parameterlos und liefern keinen Rückgabewert. In den Tests führen wir die normalen Initialisierungen durch, rufen die zu testende Business-Methode auf und überprüfen den resultierenden Zustand der Business-Objekte. Die Assertion-Klasse enthält Methoden, um diese Post-Conditions zu überprüfen. Im Beispiel wird von AssertEquals sichergestellt, dass die Konten den richtigen Betrag anzeigen. Der erste Parameter ist der erwartete Wert und der zweite der berechnete Wert.
Wenn der Code in die Datei bank.dll kompiliert ist, starten wir das NUnit-GUI und laden über File | Open die Datei bank.dll. Links wird die hierarchische Teststruktur gezeigt, rechts ist Raum für Statusmeldungen. Sobald man auf Run drückt, werden die Tests ausgeführt, wir waren hier leider nicht erfolgreich. Das Errors and Failures-Feld zeigt uns: TransferFunds : expected <250> but was <150> und im Stack Trace finden wir die Stelle, an der der Fehler ausgelöst worden ist: at bank.AccountTest.TransferFunds() in C:\nunit\BankSampleTests\AccountTest.cs:line 17.
Aber das war ja auch zu erwarten, da wir die TransferFunds-Methode noch nicht implementiert haben. Wir gehen zurück in die IDE, lassen aber das NUnit-GUI offen und geben etwa Folgendes ein:
  1. public void TransferFunds(Account destination, float amount)
  2. {
  3. destination.Deposit(amount);
  4. Withdraw(amount);
  5. }
Nach erneutem Kompilieren drücken wir wieder auf Run im NUnit-GUI. Diesmal werden die Statuszeile und der Testknopf im Baum grün. Das GUI hat die kompilierte Datei also automatisch neu geladen., sodass man das GUI immer offen lassen kann, während man in der IDE weitere Tests oder Funktionen programmiert.
Unsere Konten erhalten nun mit einem Mindeststand eine weitere Einschränkung:
  1. private float minimumBalance = 10.00F;
  2. public float MinimumBalance
  3. {
  4. get{ return minimumBalance; }
  5. }
Überziehungen werden mit einer Exception verhindert:
  1. namespace bank
  2. {
  3. using System;
  4. public class InsufficientFundsException : ApplicationException
  5. {
  6. }
  7. }
Mit einer entsprechenden Methode testen wir das gewünschte Verhalten:
  1. [Test]
  2. [ExpectedException(typeof(InsufficientFundsException))]
  3. public void TransferWithInsufficientFunds()
  4. {
  5. Account source = new Account();
  6. source.Deposit(200.00F);
  7. Account destination = new Account();
  8. destination.Deposit(150.00F);
  9. source.TransferFunds(destination, 300.00F);
  10. }
Diese Test-Methode trägt zusätzlich noch das Attribut [ExpectedException], um anzuzeigen, dass der Test-Code eine Exception erzeugen soll. Tut er dies nicht, wird der Test als fehlgeschlagen gewertet. Und sofort probieren wir den Test wieder aus. Da nach dem Kompilieren dieser Test noch nicht ausgeführt wurde, erscheint der Testbaum grau, doch nach einem Klick auf den Run-Knopf haben wir eine rote Statuszeile mit der Meldung TransferWithInsufficentFunds : InsufficientFundsException was expected.
Mit dem folgenden Abschnitt korrigieren wir das Fehlverhalten:
  1. public void TransferFunds(Account destination, float amount)
  2. {
  3. destination.Deposit(amount);
  4. if(balance-amount<minimumBalance) throw new InsufficientFundsException();
  5. Withdraw(amount);
  6. }
Testsuites ersetzen allerdings nicht den gesunden Menschenverstand, denn immer noch kann Geld aus dem Nichts erzeugt werden:
  1. [Test]
  2. public void TransferWithInsufficientFundsAtomicity()
  3. {
  4. Account source = new Account();
  5. source.Deposit(200.00F);
  6. Account destination = new Account();
  7. destination.Deposit(150.00F);
  8. try
  9. {
  10. source.TransferFunds(destination, 300.00F);
  11. }
  12. catch(InsufficientFundsException expected)
  13. {
  14. }
  15. Assertion.AssertEquals(200.00F,source.Balance);
  16. Assertion.AssertEquals(150.00F,destination.Balance);
  17. }
Kompilieren, Tests laufen lassen ... und alles rot. Wir haben 300 Euro aus dem Nichts erzeugt: denn während das Quellkonto die korrekte Bilanz von 150 Euro zeigt, enthält das Zielkonto 450 Euro. Vielleicht sollten wir den Vergleich mit dem Mindestwert vor dem Update machen?
  1. public void TransferFunds(Account destination, float amount)
  2. {
  3. if(balance-amount<minimumBalance) throw new InsufficientFundsException();
  4. destination.Deposit(amount);
  5. Withdraw(amount);
  6. }
Was würde passieren wenn die Withdraw()-Methode eine weitere Exception werfen würde? Sollten wir eine weitere Transaktion zur Korrektur auslösen? Wir müssen uns darüber irgendwann Gedanken machen, aber nicht hier und deshalb ignorieren wir es einfach. NUnit erlaubt folgendes Attribut:
  1. [Test]
  2. [Ignore("Need to decide how to implement transaction management in the application")]
  3. public void TransferWithInsufficientFundsAtomicity()
  4. { gleicher Code wie oben }
Kompilieren, laufen lassen ... eine gelbe Leiste. Klicken wir auf Tests Not Run, dann sehen wir bank.AccountTest.TransferWithInsufficientFundsAtomicity() in der Liste, auch der Grund für das Auslassen wird angezeigt.
Tests refaktorieren
So langsam sollten wir das Durcheinander in unserem Code etwas aufräumen! Alle verwendeten Test-Methoden haben ein gemeinsames Test-Objekt, dessen Initialisierung in eine Setup-Methode ausgegliedert werden kann, die von allen Tests verwendet wird (siehe Listing 3).

Listing 3
  1. namespace bank
  2. {
  3. using System;
  4. using Nunit.Framework;
  5. [TestFixture]
  6. public class AccountTest
  7. {
  8. Account source;
  9. Account destination;
  10. [SetUp]
  11. public void Init()
  12. {
  13. source = new Account();
  14. source.Deposit(200.00F);
  15. destination = new Account();
  16. destination.Deposit(150.00F);
  17. }
  18. [Test]
  19. public void TransferFunds()
  20. {
  21. source.TransferFunds(destination, 100.00f);
  22. Assertion.AssertEquals(250.00F, destination.Balance);
  23. Assertion.AssertEquals(100.00F, source.Balance);
  24. }
  25. [Test]
  26. [ExpectedException(typeof(InsufficientFundsException))]
  27. public void TransferWithInsufficientFunds()
  28. {
  29. source.TransferFunds(destination, 300.00F);
  30. }
  31. [Test]
  32. [Ignore("Need to decide how to implement transaction management in the application")]
  33. public void TransferWithInsufficientFundsAtomicity()
  34. {
  35. try
  36. {
  37. source.TransferFunds(destination, 300.00F);
  38. }
  39. catch(InsufficientFundsException expected)
  40. {
  41. }
  42. Assertion.AssertEquals(200.00F,source.Balance);
  43. Assertion.AssertEquals(150.00F,destination.Balance);
  44. }
  45. }
Auf einen Blick
  • Die Idee des Unit-Tests wurde mit dem Extremen Programmieren wiederentdeckt.
  • Alle Unit-Tests müssen ständig zu 100 Prozent funktionieren.
  • NUnit ist ein kompaktes Framework zum Unit-Testen mit .NET.
  • Tests können nur zeigen, dass es Fehler gibt, nicht, dass es keine Fehler gibt.
Zum Schluss
Unit-Testen ist nur eine der zahlreichen Arbeitstechniken, die von agilen Entwicklungsmethodologen propagiert werden. Erst aus dem Zusammenspiel mehrerer Komponenten entsteht ein tragfähiges Ganzes, das die effiziente Erzeugung stabiler Applikationen ermöglicht. Wir empfehlen [EmbraceChange], um mehr über Extremes Programmieren zu erfahren. Dr. Armin Roehrl (armin@approximity.com) und Stefan Schmiedl (s@xss.de) leiten gemeinsam die Softwareschmiede Approximity. Seit Stefan Armin auf x-Units gebracht hat, ist jeder Tag ohne einen neuen Unit-Test ein verlorener Tag.
Links und Literatur


Anzeige

Kommentare

zurück zum Seitenanfang