Grundüberlegungen von Unit Tests

Sicherlich haben die meisten Programmierer schon etwas von Unit Testing gehört und wissen grob um was es dabei geht. Die Erfahrung zeigt jedoch dass dieser wichtige Teil der Software Entwicklung oft nicht eingesetzt wird da es von Vorurteilen behaftet ist. Viele haben sich nicht genauer damit beschäftigt und wissen nicht um deren Wichtigkeit. Unter diesen vielen zählte ich leider auch eine ganze Weile! Mit Vorurteilen meine ich einerseits die Berührungsangst mit dem „Neuen“ und zum anderen die subjektive Meinung das die Erstellung solcher Tests nicht mit dem engen Zeitplan eines Projekts vereinbar ist. Ersteres muss leider jeder für sich überwinden, für den zweiten Punkt gibt es aber stichfeste Argumente dagegen.

Nehmen wir an Sie erstellen eine Anwendung die nach der Zeit immer Umfangreicher und dadurch evtl. etwas schwieriger zu Warten ist. Bei einer sauberen Umsetzung eines Software Patterns sollte das zwar nicht vorkommen aber wir nehmen ja jetzt einmal ein „worst case“ Szenario an. Sie Erstellen also die Applikation ohne Unit Tests und sparen dadurch ja Zeit. In jedem Lebenszyklus einer Anwendung kommt es vor das gewisse Änderungen in die Software finden. Um sicherzustellen dass nach solchen Änderungen Ihre Applikation noch das gewünschte Verhalten aufweist, ist ein Kompletttest des betreffenden Moduls zwingend nötig. Je nach Komplexität der Änderung oder Erweiterung kann dieser manuelle Test viel Zeit in Anspruch nehmen. Sollte jetzt noch die Zeit wegen einem Abgabetermin drücken, wird dieser manuelle Test leider viel zu oft nicht ausreichend durchgeführt. Somit entstehen früher oder später vermeidbare Softwarefehler. Sofern die manuellen Tests nach jeder Änderung in der Applikation sauber durchgeführt werden, kann zwar davon ausgegangen werden, dass keine neuen Fehler in die Software gefunden haben, jedoch sind wir jetzt wieder am zeitlichen Aspekt angelangt! Mal davon abgesehen, dass bei jedem manuellen Test durch Menschen eine gewisse Fehlertoleranz eingerechnet werden sollte.

Zugegeben: Unter Umständen spart man während der Entwicklung ohne Unit-Tests Zeit! Man sollte aber dringend einen Schritt weiter denken und die Wartung des Projekts mit einplanen! Hat man vor, eine Software längerfristig im Einsatz zu haben und stetig zu Erweitern, zahlt sich der überschaubare Mehraufwand für die Erstellung der Unit Tests definitiv aus! Bei durchdachten Tests lassen sich sogar während der Entwicklungsphase schon Fehler finden und Folgefehler vermeiden. In der Softwareentwicklung gibt es ein Verfahren namens „TDD“ (Test Driven Development). Hierbei werden sogar erst Testroutinen geschrieben, bevor es die dazugehörige Funktionalität gibt. Dazu aber später mehr.

Unit Test

Diese Grafik soll lediglich exemplarisch Veranschaulichen, dass die Unit Tests längerfristig eine Zeitersparnis im Softwareentwicklungsprozess ausmachen. Es sei noch gesagt dass eine manuelle Qualitätssicherung auch mit dem Einsatz von Unit Tests natürlich nicht obsolet wird! Der manuelle Zeitaufwand wird dabei aber deutlich verkürzt.

Jetzt wissen Sie aber immer noch nicht was Unit Tests eigentlich genau machen. Unit Tests oder auf Deutsch auch "Komponenten-Tests" genannt, werden zur maschinellen Verifizierung von Softwarekomponenten verwendet. Es handelt sich hierbei um eigen geschriebene Funktionen die wiederum Ihre Klassen-Funktionen auf korrekte Funktionsweise testen. Diese eigen geschriebenen Tests können später von separaten Tools gestartet und auf Erfolg überprüft werden. Vor allem bei Änderungen in Ihren Klassen-Funktionen erhalten Sie sofort Rückmeldung ob das Codefragment noch die gewünschte Funktionsweise aufweist. Es gibt auf dem Markt eine Reihe von solchen Modultest-Anwendungen. Ich stelle Ihnen hier NUnit vor, da es kostenlos und sehr leicht zu verwenden ist.

NUnit Referenz

Attribute

Attribute werden zum einen dazu verwendet Klassen und Methoden NUnit bekannt zu machen. Des Weiteren lassen sich diverse Einstellungen festlegen.

Befehl Beschreibung Beispiel
TestFixture

Kennzeichnet eine Klasse als Testbar. Diese Klassen müssen public sein und einen Defaultkonstruktor besitzen. Alle Klassen die dieses Attribut besitzen, werden in NUnit angezeigt.

   1:  [TestFixture]
   2:  public class DataLayerSQLTest
   3:  {
   4:    //...
   5:  }
Test

Kennzeichnet eine Funktion als Testfunktion. Diese Funktionen müssen Parameterlos und public sein. Alle Funktionen die dieses Attribut besitzen, werden in NUnit als Testmethode angeboten.

   1:  [Test]
   2:  public void UpdateAccount()
   3:  {
   4:    //...
   5:  }
Setup

Dieses Attribut kann auf eine parameterlose Methode angewandt werden, die Initialisierungscode enthält. Beispielsweise können in dieser Methode Testdaten in einer Datenbank erstellt werden, auf die die Testmethoden zugreifen können.

   1:  [SetUp]
   2:  public void Setup()
   3:  {
   4:    //...
   5:  }
TearDown

Im Gegensatz zum Attribut Setup wird die Funktion die dieses Attribut besitzt, am Ende des Testdurchlaufs aufgerufen. Hier können Aufräumarbeiten stattfinden, beispielsweise das Löschen der Testdaten.

   1:  [TearDown]
   2:  public void TearDown()
   3:  {
   4:    //...
   5:  }
Ignore

Verwenden Sie dieses Attribut für Test-Klassen oder Test-Methoden um diese temporär aus Testdurchlauf zu nehmen. In NUnit wird der Testfall dann Gelb markiert.

   1:  [Test]
   2:  [Ignore]
   3:  public void UpdateAccount()
   4:  {
   5:    //...
   6:  }
   7:   
   8:   
   9:  [TestFixture]
  10:  [Ignore]
  11:  public class DataLayerSQLTest
  12:  {
  13:    //...
  14:  }
ExpectedException

Sofern eine Testmethode eine Exception werfen soll, kann dies mit diesem Attribut überprüft werden. Es gibt einige Überladungen mit denen der entsprechende Typ angegeben werden kann. Sofern die Testroutine keine bzw. eine andere Ausnahme wirft, gilt der Test als nicht bestanden.

   1:  [Test]    [ExpectedException(
   2:  typeof(DAException))]
   3:  public void CreateDoubleAccount()
   4:  {
   5:    //...
   6:  }


Assertions

Die Assertions bieten Ihnen eine weitere Möglichkeit zu überprüfen, ob das Ergebnis einer Testroutine richtig oder falsch ist. Sie geben damit quasi an ob der Test als bestanden oder nicht bestanden gewertet werden soll. Wenn eine Assertion ausgelöst wird, ist der Test fehlgeschlagen und wird in NUnit rot dargestellt.

Die Klasse NUnit.Framework.Assert bietet viele verschiedene Methoden und Überladungen an, die zur einfachen Überprüfung von Ergebnissen beitragen. In der folgenden Tabelle ist nur ein kleiner Teil aufgelistet. Viele dieser Methoden sind auch selbsterklärend.

Befehl Beschreibung Beispiel
Assert.AreEqual

Überprüft ob zwei Objekte identisch sind. Wenn nicht gilt der Test als nicht bestanden.

   1:  Assert.AreEqual(account.Amount, 10.08);
Assert.AreNotEqual

Überprüft ob zwei Objekte ungleich sind. Wenn nicht gilt der Test als nicht bestanden.

   1:  Assert.AreNotEqual(
   2:  account.Amount, 0);
Assert.Fail

Diese Anweisung führt zum Auslösen eines Asserts ohne Überprüfung. Verwenden Sie diese Methode beispielsweise, wenn die Testroutine in einen Code-Teil läuft der nicht aufgerufen werden darf.

   1:  Assert.Fail();
Assert.IsFalse

Überprüft ob der Vergleich zweier bool Werte ungleich ist. Wenn nicht gilt der Test als nicht bestanden.

   1:  Assert.IsFalse(
   2:  account.MinimalAmount== 0);
Assert.IsNotNull

Überprüft ob das übergebene Objekt nicht Null ist. Wenn nicht gilt der Test als nicht bestanden.

   1:  BankAccount getAccount = _data.GetAccount
   2:  (account.AccountNumber);
   3:   
   4:  Assert.IsNotNull(getAccount, 
   5:  "object could not be found");
Assert.IsInstanceOfType

Überprüft ob ein Objekt vom angegebenen Typ ist. Wenn nicht gilt der Test als nicht bestanden.

   1:  Assert.IsInstanceOfType(
   2:  typeof(BankAccount), retObj);


Praxis-Beispiel

Damit die erste Hürde von Unit Tests leichter genommen wird, möchte ich Ihnen die Verwendung anhand eines Praxis-Beispiels näher bringen. Im folgenden Beispiel versuche ich Schritt für Schritt zu verdeutlichen wie Sie Unit Tests in die Applikation sinnvoll einbringen können. Das Beispiel MBBankSample und das dazugehörige Testprojekt können Sie sich hier herunterladen. Im Artikel werde ich nur Ausschnitte zeigen können, da der komplette Code zu umfangreich wäre.

Sofern Sie NUnit noch nicht installiert haben, erhalten Sie auf http://nunit.org/index.php?p=download die aktuelle Version zum Download. Die Installation an sich läuft wie gewohnt ab und bedarf keiner zusätzlichen Erklärung.

Jetzt geht es an das Erstellen eines neuen Projektes im Visual Studio. Da wir eine Wartungsfreundliche und langlebige Applikation erstellen möchten, ist der Einsatz einer Softwarearchitektur unabdingbar. Ich habe mich jetzt für eine klassische 3 Schichten Applikation entschieden bei dem die Präsentationsschicht nochmals mittels des MVP Pattern unterteilt ist. Wieso diese Unterteilung von Vorteil ist, werden wir bald erkennen.

Um die Anwendungs-Schichten sauber voneinander zu trennen, liegen sie in separaten Assemblies. Dies ist natürlich nicht notwendig es schafft m. E. aber eine bessere Abstraktion. Hier nun der Aufbau unserer Beispielanwendung:

MBBankSample - Solution Explorer

Es gibt mehrere Möglichkeiten Unit-Tests zu implementieren. Entweder in einem neuen Projekt oder direkt im Applikationscode. Letzteres empfehle ich Ihnen nur bei sehr kleinen Anwendungen. Da ansonsten die Übersichtlichkeit bei der Vermischung zwischen Produktionscode und Testcode leidet. Wir legen daher für unser Beispiel ein neues C# Klassenprojekt an und nennen es "UnitTest". Dort sollte ebenfalls eine ähnliche Struktur wie bei der zu testenden Anwendung angelegt werden damit man die Testmethoden auch schnell wieder findet. Damit unsere Testanwendung Zugriff auf die zu testende Anwendung erhält, fügen wir die Assemblies als Referenz hinzu. Somit können wir nun jede öffentliche Klasse testen. In diesem Schritt muss auch eine Referenz zum NUnit Framework hergestellt werden. Wählen Sie dafür im Referenz-Dialog den Eintrag "nunit.framework".
Als Resultat dieses Projekts erhalten wir eine DLL-Datei. Diese DLL-Datei kann mit dem Programm NUnit.exe geladen und überprüft werden. Jedoch erst wenn es entsprechenden Testcode enthält.

UnitTest - Solution Explorer

Testen der Datenzugriffsschicht

Fangen wir an die Datenzugriffsschicht (MBBankSample.DA.SQL.DataLayerSQL) auf Herz und Nieren zu testen. Da die Struktur der zu testenden Anwendung beibehalten werden sollte, erstellen wir zunächst einen Ordner "NUnitTest.DA.DataLayerSQL" und darunter eine neue Klassendatei namens "DataLayerSQLTest.cs". Damit das NUnit-Framework unsere Klasse als "Testklasse" erkennt, weisen wir der Klasse das Attribut "[TestFixture]" zu.

   1:   [TestFixture]
   2:      public class DataLayerSQLTest
   3:      {
   4:   
   5:          private DataLayerSQL _data;
   6:          private BankAccount _account;

In dieser Klasse sollten jetzt _alle_ öffentlichen Methoden der Klasse DataLayerSQL getestet werden. Da diese Klasse das Interface IDataLayer implementiert, sind das folgende:


   1:  BankAccount GetAccount(string accountNumber);
   2:  void UpdateAccont(BankAccount account);
   3:  void CreateAccount(BankAccount account);
   4:  void DeleteAccount(BankAccount account);


Jede dieser Funktionen sollte mit mehreren Testmethoden auf Fehler getestet werden. Eine Testmethode Kennzeichnen Sie mit dem Attribut "[Test]". Aus Erfahrung würde ich mindestens eine Methode für den generellen Funktionsumfang erstellen, eine die ungültige Parameter übergibt und des weiteren ein Testszenario indem ein logischer Fehler provoziert wird. Generell sollte beim Erstellen von Testmethoden darauf geachtet werden, den Grenzbereich einer Funktion zu erreichen. Diesen Grenzbereich kennt natürlich der Entwickler der Methode selbst am besten gerade wenn die Funktion eben erst erstellt wurde, ist der genaue Ablauf noch im Gedächtnis. Daher sollte zwischen Erstellen einer Methode und eines dazugehörigen Testszenario nicht mehr als ein Tag vergehen! Testmethoden für die Methode "CreateAccount" könnten beispielsweise folgendermassen aussehen:

   1:  //===========================================================================================
   2:  [Test]
   3:  public void CreateNewAccount()
   4:  //===========================================================================================
   5:  {
   6:      BankAccount account = new BankAccount();
   7:   
   8:      account.AccountNumber = "11223344";
   9:      account.Amount = 100.01;
  10:      account.BankCode = "187654321";
  11:      account.CustomerNumber = "123B";
  12:      account.FirstName = "UnitTestFirstname2";
  13:      account.SurName = "UnitTestSurname2";
  14:      account.MinimalAmount = 10.99;
  15:      
  16:      
  17:      try
  18:      {
  19:          _data.CreateAccount(account);
  20:   
  21:          BankAccount getAccount = _data.GetAccount(account.AccountNumber);
  22:   
  23:          Assert.IsNotNull(getAccount, "New Created bank object could not be found");
  24:          Assert.IsTrue(account.Equals(getAccount), "Objects aren't equal");
  25:      }
  26:      finally
  27:      {
  28:          //Delete account
  29:          _data.DeleteAccount(account);
  30:      }
  31:  }
  32:   
  33:   
  34:   
  35:  //===========================================================================================
  36:  [Test]
  37:  public void CreateNewAccountWIP()
  38:  //===========================================================================================
  39:  {
  40:      try
  41:      {
  42:          _data.CreateAccount(null);
  43:          Assert.Fail("Create Account accept a null argument");
  44:      }
  45:      catch (Exception exp)
  46:      {
  47:          Assert.IsTrue(exp is ArgumentNullException, exp.GetType().ToString());
  48:      }
  49:   
  50:      try
  51:      {
  52:          _data.CreateAccount(new BankAccount());
  53:      }
  54:      catch (Exception exp)
  55:      {
  56:          Assert.IsTrue(exp is DAException, exp.GetType().ToString());
  57:      }
  58:  }
  59:   
  60:   
  61:   
  62:    
  63:  //===========================================================================================
  64:  [Test]
  65:  [ExpectedException(typeof(DAException))]
  66:  public void CreateDoubleAccount()
  67:  //===========================================================================================
  68:  {
  69:      BankAccount account = new BankAccount();
  70:   
  71:      account.AccountNumber = "11223344";
  72:      account.Amount = 100.01;
  73:      account.BankCode = "187654321";
  74:      account.CustomerNumber = "123B";
  75:      account.FirstName = "UnitTestFirstname2";
  76:      account.SurName = "UnitTestSurname2";
  77:      account.MinimalAmount = 10.99;
  78:   
  79:      try
  80:      {
  81:          _data.CreateAccount(account);
  82:          _data.CreateAccount(account);
  83:      }
  84:      finally
  85:      {
  86:          _data.DeleteAccount(account);
  87:      }
  88:  }

"CreateNewAccount" prüft die normale Funktionsweise, "CreateNewAccountWIP" übergibt ungültige Parameter und "CreateDoubleAccount" verursacht einen logischen Fehler indem versucht wird zwei identische Bankkonten zu erstellen.
Das Testprojekt sollte nun folgende Struktur besitzen:

UnitTest - Solution Explorer

Testen mit NUnit

Nachdem alle Testroutinen geschrieben sind, kann das Projekt kompiliert werden. Als Ergebnis erhalten wir eine DLL-Datei. Starten Sie nun NUnit.exe und laden die Assembly als Projekt. Nach Betätigen der Schaltfläche "Run" werden alle Routinen welche das Attribut "[Test]" besitzen, aufgerufen und ausgewertet. Sollte eine dieser Testroutinen ein unerwartetes Ergebnis zurückgeben, schlägt der Test fehl indem der Eintrag rot dargestellt wird. Somit kann nach jeder Erweiterung oder Änderung am Programm per "Klick" die Funktionsweise überprüft werden. Im folgendem Screenshot erkennt man das die Methode "CreateDoubleAccount" keine DAException wirft, obwohl dies zu erwarten wäre.

UnitTest - Solution Explorer

Im nächsten Teil werden die zwei anderen Schichten (Anwendungslogik und Präsentation) getestet. Ich gehe auch darauf ein, wie Sie mit NUnit Debuggen können um die Ursache eines nicht bestandenen Tests schnell zu finden.


Download: MBBankSample mit Testprojekt

Download: Artikel als PDF