Unit Tests mit Visual Studio 2008

VS08 Unit Test Menüpunkt In Visual Studio Professional 2008 ist der neue Menüpunkt Test enthalten, der bisher der Team-System-Edition vorbehalten war. Unter diesem Menüpunkt kann man einem Projekt Unit-Tests hinzufügen oder vorhandene Tests ausführen.

Ein Unit-Test ist ein Modultest, der die Funktionen eines Moduls auf Korrektheit prüft. Im Rahmen der Unit-Tests werden die Funktionen des zu prüfenden Moduls aufgerufen, und das Resultat mit dem erwarteten Ergebnis verglichen. Abweichungen und Übereinstimmungen werden auswertbar und vollautomatisch dokumentiert.

Gerade für Refactoring, der permanenten Überarbeitung von Programmcode, sind jederzeit wiederholbare Tests notwendig, um die Qualität der produzierten Software zu erhöhen. Ungewollte Verschlimmbesserungen werden schnell erkannt. Eine ständige Optimierung des vorhandenen Codes wird dadurch ermutigt. Ohne Unit-Tests gilt die alte Regel never change a running system, die zu Stillstand und zu schwer wartbarem veraltetem Code führt.

NUnit

Schon seit vielen Jahren kann man NUnit einsetzen, das aber zusätzlich installiert werden muss und dessen Integration in die Entwicklungsumgebung Zusatztools erfordert. Außerdem muss man die NUnit-Bibliotheken händisch zum Testprojekt hinzulinken, und die Testroutinen für jede zu prüfende API-Funktion manuell erzeugen.
Jetzt hat jeder Entwickler immer automatisch die passende Bibliothek, und Test-Stubs können auch für nachträglich hinzugefügte Funktionen automatisch erzeugt werden. Hierzu reicht ein Klick im Funktionskopf auf die rechte Maustaste.
Endlich sind Unit-Tests ein First-Citizen der Entwicklungsumgebung, was die Akzeptanz bei den Entwicklern erhöht.

NUnit

Die Syntax ist bei NUnit und beim in VS08 integrierten Unit-Testing leider nicht identisch. Was Microsoft getrieben hat, hier inkompatible Namen einzuführen, dürfte klar sein: Wettbewerb erschweren. Bei Namensgleichheit der Annotationen, die leicht zu haben gewesen wäre, würde ein Wechsel der Testumgebung leichter fallen.

Attributname NUnit Visual Studio
Test Fixture [TestFixture] [TestClass]
Set Up [SetUp] [TestInitialize]
Tear Down [TearDown] [CleanUp]
Test [Test] [TestMethod]

Im Rahmen von Visual Studio Team System wird kann für ausgeführte Unit-Tests auch die Code Coverage ermittelt und angezeigt werden. Mit der Testabdeckung kann im Rahmen der Unit-Tests festgestellt werden, ob die Tests auch möglichst alle Code-Pfade abdecken. Nur Code, der getestet wurde, ist Code, der einigermaßen unbedenklich verwendet werden kann.
Das Feature Code-Coverage fehlt in der Professional-Version , ebenso wie ein Profiler oder Last-Tests.

Beispiel für einen Unit-Test

Im .NET-Framework fehlt auch in der neuesten Version 3.5 immer noch eine string.Replace-Funktion, die Groß/Kleinschreibung ignoriert. Zwar kann man mittels Regular Expressions den gewünschten Zweck erreichen, allerdings ist der Code hierfür aufwendiger, unlesbarer und weniger performant.

Um eine neue Replace-Funktion zu schaffen, kann man das Pferd von vorne aufzäumen, und zuerst die Unit-Tests schreiben, in Anlehnung an die TDD-Entwicklungstechnik. Allerdings sollte man die Tests nach dem Schreiben des Codes noch ergänzen, denn erst danach fallen einem oft noch denkbare Problemfälle auf, die prüfenswert sind.

    1 using MyExtensions;

    2 using Microsoft.VisualStudio.TestTools.UnitTesting;

    3 using System;

    4 

    5 namespace TestExtensions

    6 {

    7     /// <summary>

    8     /// Unit-Tests der .Replace-Funktion.

    9     ///</summary>

   10   [TestClass()]

   11   public class MyExtensionsStringTest

   12   {

   13     /// <summary>

   14     /// Tests der Replace-Funktion, Teil 1 bis n.

   15     /// Aufruf Check:

   16     /// "Quell-String", "Such-String", "Ersatz-String", "Soll-Ergebnis"

   17     ///</summary>

   18     [TestMethod()] public void ReplaceTest1()

   19     {Check("Dies ist ein Test", "ein", "kein", "Dies ist kein Test");}

   20     [TestMethod()] public void ReplaceTest2()

   21     {Check("Dies ist ein Test", "EIN", "kein", "Dies ist kein Test");}

   22     [TestMethod()] public void ReplaceTest3()

   23     {Check("Dies ist ein Test", "eIn", "kein", "Dies ist kein Test");}

   24     [TestMethod()] public void ReplaceTest4()

   25     {Check("Dies ist ein Test", "x", "y", "Dies ist ein Test");}

   26     [TestMethod()] public void ReplaceTest5()

   27     {Check("Dies ist ein Test", "Dies ist ein Test xyz", "y", "Dies ist ein Test");}

   28     [TestMethod()] public void ReplaceTest6()

   29     {Check("Dies ist ein Test", "ABC Dies ist ein Test", "y", "Dies ist ein Test");}

   30     [TestMethod()] public void ReplaceTest7()

   31     {Check("Dies ist ein Test", "dies", "Es", "Es ist ein Test");}

   32     [TestMethod()] public void ReplaceTest8()

   33     {Check("Dies ist ein Test", "Test", "Rest", "Dies ist ein Rest");}

   34     [TestMethod()] public void ReplaceTest9()

   35     {Check("Dies ist ein Test", "e", "a", "Dias ist ain Tast");}

   36     [TestMethod()] public void ReplaceTest10()

   37     {Check("Dies ist ein Test", "e", "e", "Dies ist ein Test");}

   38     [TestMethod()] public void ReplaceTest11()

   39     {Check("Dies ist ein Test", "ein", "ein", "Dies ist ein Test");}

   40     [TestMethod()] public void ReplaceTest12()

   41     {Check("Dies ist ein Test", "e", "ee", "Diees ist eein Teest");}

   42     [TestMethod()] public void ReplaceTest13()

   43     {Check("Dies ist ein Test", "D", "DD", "DDies ist ein Test");}

   44     [TestMethod()] public void ReplaceTest14()

   45     {Check("Dies ist ein Test", "t", "ttt", "Dies isttt ein tttesttt");}

   46     [TestMethod()] public void ReplaceTest15()

   47     {Check("Dies ist ein Test", "Dies ist ein Test", "", "");}

   48     [TestMethod()] public void ReplaceTest16()

   49     {Check("Dies ist ein Test", "Dies ist ein Test", "Dies ist ein Test", "Dies ist ein Test");}

   50     [TestMethod()] public void ReplaceTest17()

   51     {Check("t", "t", "a", "a");}

   52     [TestMethod()] public void ReplaceTest18()

   53     {Check("t", "x", "y", "t");}

   54     [TestMethod()] public void ReplaceTest19()

   55     {Check("ttt", "t", "x", "xxx");}

   56     [TestMethod()] public void ReplaceTest20()

   57     {Check("ttt", "t", "", "");}

   58     [TestMethod()] public void ReplaceTest21()

   59     {Check("abctttabc", "tt", "", "abctabc");}

   60     [TestMethod()] public void ReplaceTest22()

   61     {Check("", "abc", "xyz", "");}

   62     [TestMethod()] public void ReplaceTest23()

   63     {Check(null, "abc", "xyz", null);}

   64     [TestMethod()] public void ReplaceTest24()

   65     {ThrowsException("Dies ist ein Test", null, "x");}

   66     [TestMethod()] public void ReplaceTest25()

   67     {ThrowsException("Dies ist ein Test", "", "x");}

   68     [TestMethod()] public void ReplaceTest26()

   69     {ThrowsException("Dies ist ein Test", "abc", null);}

   70 

   71     /// <summary>

   72     /// string.Replace-Testfunktion.

   73     /// Es wird geprüft, ob das erwartete Ergebnis herauskommt.

   74     /// </summary>

   75     /// <param name="vsSource">Quellstring</param>

   76     /// <param name="vsSearch">zu suchender String</param>

   77     /// <param name="vsReplace">Ersatz-String</param>

   78     /// <param name="vsResult">Soll-Ergebnis</param>

   79     void Check(string vsSource, string vsSearch, string vsReplace, string vsResult)

   80     {

   81       string lsMsg = vsSource.Quote() + ".Replace(" + vsSearch.Quote()

   82         + ", " + vsReplace.Quote() + ") = ";

   83       string lsResult = "";

   84       bool lbException = false;

   85       try

   86       {

   87         lsResult = vsSource.Replace(vsSearch, vsReplace,

   88           StringComparison.OrdinalIgnoreCase);

   89       }

   90       catch (Exception ex)

   91       {

   92         Assert.Fail("exception: " + ex.Message + ", call: " + lsMsg);

   93         lbException = true;

   94       }

   95       if (!lbException)

   96       {

   97         lsMsg += lsResult.Quote();

   98         Assert.AreEqual(vsResult, lsResult, lsMsg);

   99       }

  100     }

  101 

  102     /// <summary>

  103     /// Testfunktions-Aufruf für string.Replace,

  104     /// bei der eine Exception erwartet wird.

  105     /// </summary>

  106     /// <param name="vsSource">Quell-String</param>

  107     /// <param name="vsSearch">Such-String</param>

  108     /// <param name="vsReplace">Ersatz-String</param>

  109     void ThrowsException(string vsSource, string vsSearch, string vsReplace)

  110     {

  111       string lsMsg = vsSource.Quote() + ".Replace(" + vsSearch.Quote()

  112         + ", " + vsReplace.Quote() + ") = ";

  113       bool lbException = false;

  114       try

  115       {

  116         vsSource.Replace(vsSearch, vsReplace, StringComparison.OrdinalIgnoreCase);

  117       }

  118       catch (Exception ex1)

  119       {

  120         lbException = true;

  121       }

  122       if (lbException)

  123       {

  124         Assert.IsTrue(true, "ok");

  125       }

  126       else

  127       {

  128         Assert.Fail("exception should have been thrown: " + lsMsg);

  129       }

  130     }

  131 

  132     private TestContext testContextInstance;

  133     public TestContext TestContext

  134     {

  135       get  {return testContextInstance;}

  136       set  {testContextInstance = value;}

  137     }

  138   }

  139 }

Wie man sieht, kann die Anzahl Unit-Tests für eine einzige Funktion schnell sehr hoch werden, ohne jemals auch nur annähernd vollständig zu sein. Wer noch gute Ideen für weitere Tests hat, oder eine eigene string.Replace-Funktion geschrieben hat: Her damit, oder schreibt in Eurem Blog darüber!

Nachfolgend eine Replace-Funktion, die zumindest die obigen Unit-Tests bestand.

   87     /// <summary>

   88     /// Ersetzt in einem String den Suchstring durch den Ersatzstring

   89     /// </summary>

   90     /// <param name="vsData">der String, in dem die Vorkommen ersetzt werden sollen</param>

   91     /// <param name="vsSearchFor">Suchwort, das ersetzt werden soll</param>

   92     /// <param name="vsReplace">Ersatz-String</param>

   93     /// <param name="veType">Groß- Kleinschreibung beim Suchstring beachten?</param>

   94     /// <returns>den String, in dem die Vorkommen vom Suchstring ersetzt worden sind</returns>

   95     /// <remarks>

   96     /// Weder Suchstring noch Ersatzstring dürfen <code>null</code> sein.

   97     ///  Der originale String bleibt unverändert (string ist immutable),

   98     /// der Ergebniswert enthält die veränderten Daten.

   99     /// Alternativ könnte auch ein RegExp benutzt werden, allerdings sind diese umständlicher

  100     /// zu lesen und langsamer in der Ausführung in den meisten Fällen.

  101     /// Bei großen Strings mit vielen Ersetzungen sollte eine Variante erstellt

  102     /// werden, die intern einen <code>StringBuilder</code> benutzt.

  103     ///

  104     /// Achtung:

  105     /// Nach mehr als <value>n</value> Schleifendurchläufen wird mit einer Exception abgebrochen,

  106     /// um eine vermutete Endlosschleife abzubrechen. Nach Geschmack diese Restriktion entfernen.

  107     /// </remarks>

  108     public static string Replace(this string vsData, string vsSearchFor, string vsReplace, StringComparison veType)

  109     {

  110       if (string.IsNullOrEmpty(vsData))

  111       {

  112         return vsData;

  113       }

  114       else if (string.IsNullOrEmpty(vsSearchFor))

  115       {

  116         throw new ArgumentException("string to search for may not be null or empty");

  117       }

  118       else if (vsReplace == null)

  119       {

  120         throw new ArgumentException("replace-string may not be null");

  121       }

  122       else

  123       {

  124         // maximale Anzahl an Ersetzungen, bei mehr wird eine Endlos-Schleife angenommen

  125         // und eine Exception ausgelöst

  126         const int MaxLoop = 10000;

  127         int lnCounter = 0; // Anzahl erfolgter Ersetzungen

  128         int lnStartPos = 0;

  129         string lsResult = vsData;

  130         int lnLength = vsSearchFor.Length;

  131         do

  132         {

  133           int lnPos = lsResult.IndexOf(vsSearchFor, lnStartPos, veType);

  134           if (lnPos >= 0)

  135           {

  136             lsResult = lsResult.Substring(0, lnPos) + vsReplace + lsResult.Substring(lnPos + lnLength);

  137             lnStartPos = lnPos + vsReplace.Length;

  138             if (lnStartPos >= lsResult.Length)

  139             {

  140               break;

  141             }

  142           }

  143           else

  144           {

  145             break;

  146           }

  147           lnCounter++;

  148           if (lnCounter > MaxLoop)

  149           {

  150             throw new ArgumentException("too many replacements, max is " + MaxLoop);

  151           }

  152         }

  153         while (true);

  154         return lsResult;

  155       }

  156     }

Wer möchte, kann den Code sicher noch verbessern - der Reiz aber liegt darin, ohne Kenntnis der obigen Funktion eine eigene Replace-Funktion zu schreiben, und gegen die Unit-Tests laufen zu lassen.

Tipps für Unit-Tests

  • Bei einem Unit-Test wird zuerst die Korrektheit der Ergebnisse geprüft durch einen Soll/Ist-Vergleich.
  • Es folgen Tests mit Werten, von denen man annimt, dass sie zu fehlerhaften Ergebnissen führen könnten. Dabei sind eine Einsicht in den Quelltext und jahrelange Erfahrung hilfreich.
  • Besondere Beachtung verdienen Randwerte, wie null als Parameter, überlange Strings oder negative Zahlen.
  • Zu prüfen sind auch Exceptions. Es muss durch geeignete absichtliche Aufrufe mit fehlerhaften Parametern getestet werden, ob die erwartete Exception auch wirklich ausgelöst wird.
  • Ein Unit Test kann auch die Performance eines Aufrufs messen und bei zu langer Dauer des Aufrufs den Assert scheitern lassen.
  • Unit-Tests sollten optimalerweise in den Build-Process nahtlos integriert werden. Mindestens ein Lauf vor jedem Export in die Produktion ist Pflicht.
  • Vor oder direkt nach dem Einchecken neuen Codes müssen die Unit-Tests erfolgreich durchlaufen worden sein.
  • Das Ziel eines Unit-Tests ist es, Fehler aufzudecken. Wünschenswert ist es daher, wenn nicht ausschließlich der Entwickler selbst die Unit-Tests schreibt. Unit-Tests sind gemeinsam gepflegter Code, der nicht einer bestimmten Person „gehört“.
  • Sobald man in einer Funktion einen Fehler findet, sollte man die Unit-Tests um einen Test erweitern, der ein erneutes Auftreten dieses Fehlers erkennt.
  • Unit-Tests sind wie „normaler“ Code zu behandeln, sie gehören genauso in die Versionsverwaltung. Auch Unit-Tests können Fehler enthalten, daher gibt es auch für Unit-Tests die normale Wartung. Unit-Tests für Unit-Tests sind aber natürlich Unsinn :-)
  • Empfehlenswert ist es, zumindest beim ersten Durchlauf eines neuen Unit-Tests die Schritte im Debugger mitzuverfolgen. Hierbei gewinnt man oft noch Anregungen für neue Tests, oder entdeckt denkbare Fehler.
  • Da Unit-Tests normaler Code sind, müssen schwierigere Teile in Kommentaren erläutert werden. Alternativ kann auf externe Dokumentation verwiesen werden, auch ein Hyperlink auf eine zugehörige RFC oder ISO-Norm kann hilfreich sein.
  • Unit-Tests für Alles und Jedes werden zwar oft gefordert, aber das ist unrealistisch. Bei Kernkomponenten oder Finanz-Anwendungen sind umfassende und intensive Unit-Tests natürlich Pflicht. Es kann immer beliebig viel Aufwand getrieben werden, der aber nicht immer bezahlbar und begründbar ist. Es müssen die Kosten gegen den Nutzen abgewogen werden. Es sollte aber mindestens ein positiver Unit-Test vorhanden sein, der mindestens den Haupt-Codepfad abdeckt.

VS08 Unit Tests

Fazit

Der große Vorteil von Unit-Tests ist, dass sie jederzeit und nachvollziehbar wiederholt werden können. Das ständige Verbessern des Codes wird dadurch ermutigt und überhaupt erst ermöglicht. Manuelle Tests sind lästig und unbequem und daher unzuverlässig, denn sie werden aufgrund des damit verbundenen Aufwands nicht regelmäßig gemacht und sind oft nicht exakt reproduzierbar.

Nachteil ist der damit verbundene Aufwand. Unit-Tests zu erstellen ist oftmals einfacher, als den Original-Code zu schreiben, erfordert aber auch einige Überlegungen, Sorgfalt und vor allem immer etwas von der sowieso viel zu knappen Zeit. Bei Änderungen im Hauptquelltext muss zudem oft auch der Unit-Test angepasst werden.

Wenn man aber weiß, dass Code häufig geändert wird, oder Fehler im Code viel Geld kosten könnten, sind Unit-Tests ein elegantes und sehr hilfreiches Werkzeug. Sie sind weniger lästig als wiederholte manuelle Tests oder teure Supportfälle.

Durch die direkte Integration in Visual Studio wird es einem noch leichter gemacht. NUnit wird damit für Neuprojekte überflüssig. Für die Visual-Studio Standard/Express-Versionen wird es aber weiterhin wertvolle Dienste leisten können. Beim Hinzufügen einer neuen Methode reicht ein Mausklick, um die Hülle für einen passenden Unit-Test zu erzeugen.
Richtig Spaß wird es erst mit der großen und normalerweise teuren Lösung Visual Studio Team System machen. Diese gab es im Rahmen der Microsoft-Veranstaltung namens ready.for.take.off in Frankfurt mit über 7.000 Teilnehmern als Vollversion für jeden Teilnehmer. Bis dieser Server hier aufgesetzt ist, gibt es noch andere Lösungen für die Felder Code Coverage und Profiler. Für die Annalen: In VS2003 war der Profiler noch enthalten!

Unit-Tests sind kein Allheilmittel. Genauso wichtig ist die User-Akzeptanz, schnelle Ablaufzeiten, gutes Design und Integrationstests. Hilfreich sind sie dennoch – den Aufwand wird man in der Praxis aus zeitlichen Gründen nur für Kernbibliotheksfunktionen und andere wichtige Bestandteile treiben können.
Ein Unit-Test wird zwar niemals die Korrektheit beweisen können, kann aber die Änderungsfreudigkeit erhöhen und die Code-Qualität verbessern.