Unit-Tests mit Visual-Studio-2008 Professional
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.
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.
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.