Laden...

[Artikel] Unit-Tests: Einführung in das Unit-Testing mit VisualStudio

Letzter Beitrag vor 10 Jahren 2 Posts 39.073 Views
[Artikel] Unit-Tests: Einführung in das Unit-Testing mit VisualStudio

Unit-Tests sind ein unentbehrliches Hilfsmittel für jeden Programmierer, um die Funktionsfähigkeit seiner Software sicherzustellen. Programmfehler kosten Zeit und Geld, daher benötigt man eine automatisierte Lösung, um diesen Fehlern auf die Spur zu kommen - und zwar möglichst bevor die Software zum Einsatz kommt. Unit-Tests sollten überall dort eingesetzt werden, wo professionell Software entwickelt wird. Daher wird von einem Software-Entwickler heute auch immer erwartet, dass er Erfahrungen mit Unit-Tests hat, und selbst Open-Source-Bibliotheken werden heute zumeist auch mit einem dazugehörigen Test-Projekt ausgeliefert. Deshalb soll dieser Artikel einen schnellen Einstieg und ein Verständnis für den Nutzen der Unit-Test ermöglichen.

Motivation
Die intuitive Herangehensweise zum Aufspüren von Fehlern ist es, das Programm zu starten und alle Funktionen der Reihe nach auszuprobieren, bis es zu einem Fehler kommt. Dann kann man diesen Fehler mit Hilfe des Debuggers (siehe [Artikel] Debugger: Wie verwende ich den von Visual Studio?) beheben. Nun muss man wieder von vorne anfangen, alle Funktionen des Programms auszuprobieren, da man bei der Behebung des ersten Fehlers einen neuen Fehler eingebaut haben könnte. Diese Vorgehensweise bringt aber zwei Probleme mit sich:* Der Aufwand zum Testen steigt quadratisch mit dem Funktionsumfang der Software

  • Die Vollständigkeit und die Reihenfolge der Funktionsaufrufe hängen vom Tester ab, und können nicht garantiert oder nachgeprüft werden

**
Ein einfacher Unit-Test**
Der erste Schritt, diese Herangehensweise zu vereinfachen, besteht darin, Testcode zu schreiben und auszuführen. Dazu schreibt man sich eine kleine Methode, die eine Funktionalität des Programms aufruft und überprüft, ob diese fehlerfrei ausgeführt wurde. Fehlerfrei soll hier bedeuten, dass keine Exception geworfen und das richtige Ergebnis zurückgeliefert wurde. Ein einfaches Beispiel, welches eine eigene Implementierung der Addition von drei Zahlen testen soll, würde dann so aussehen:


void TestAddition()
{
	int a = 1;
	int b = 2;
	int c = 3;
	int expectedResult = 6; // 1 + 2 + 3
	try 
	{
		int result = MyMathClass.Add(a, b, c);
		if (result != expectedResult)
			Console.Write("(Error) Expected value is {0}, actual value is {1}", result, expectedResult);
	}
	catch (Exception e)
	{
		Console.Write("(Error) " + e.ToString());
	}	
}

Mithilfe dieser kleinen Testmethode können wir nun garantieren, dass die Add-Methode das richtige Ergebnis liefert - zumindest für die Summe von 1, 2 und 3. Sollte der Wert trotz allem irgendwo im Programm abweichen, liegt es somit garantiert nicht an der Add-Methode. Und genau darin besteht der Vorteil der automatisierten Tests, denn nun können wir den Fehler Schritt für Schritt eingrenzen, indem wir weitere Test-Methoden schreiben.

Unit-Tests mit Visual Studio
Man sollte sich angewöhnen, für jedes Projekt in Visual Studio ein dazugehöriges Test-Projekt zu erstellen. Für jede Funktionalität, die man in seinem Projekt implementiert, sollte man mindestens einen Test-Fall im Test-Projekt erstellen. Über "Datei" -> "Neues Projekt" -> "C# Test-Projekt" können wir ein Projekt erstellen, bei dem das Microsoft Test-Framework bereits referenziert wird. Die oben angeführte Test-Methode würde dann so aussehen:


[TestClass]
public class MathTests
{
	[TestMethod]
	public void TestAddition()
	{
		int a = 1;
		int b = 2;
		int c = 3;
		int expectedResult = 6;
		int result = MyMathClass.Add(a, b, c);
		Assert.AreEqual(expectedResult, result);
	}
}

Hier fallen die Attribute für die Test-Klasse und die Methode für unseren Test-Fall auf, sowie die vereinfachte Überprüfung des Ergebnisses mit Hilfe der Assert-Klasse. Weitere oft verwendete Assert-Methoden sind IsNotNull, IsTrue oder IsFalse.

Um die Behandlung von Exceptions muss man sich meistens nicht mehr kümmern. Sobald ein Test eine Exception wirft, wird der Test als "failed" markiert. Soll jedoch überprüft werden, ob der Code eine Exception an der richtigen Stelle wirft, kann man das ExpectedException-Attribut oder die Assert.ThrowsException-Methode (ab VS 2012) verwenden.

Ein weiteres Test-Framework, das oft zum Einsatz kommt, ist NUnit. Auch hier werden Attribute und Assertations ähnlich wie im Microsoft Test-Framework eingesetzt. Auch für JavaScript und andere Programmiersprachen gibt es Test-Frameworks.

Starten der Unit-Tests
Im Test-Menü gibt es einige Funktionen und Fenster zum Verwalten und Ausführen der Unit-Tests in einer Solution. Besonders hervorzuheben ist der Testlisten-Editor (bzw. der Test-Explorer ab Visual Studio 2012), mit dem man Test-Methoden zu Listen zusammenstellen kann, um diese dann einzeln oder in einem Schritt auszuführen. Weiterhin gibt es die Möglichkeit, die Tests direkt aus dem Code-Fenster oder dem Test-Explorer zu starten (siehe How to: Run Tests from Microsoft Visual Studio). Praktischerweise werden erfolgreiche Tests grün markiert und fehlerhafte rot. Wichtig ist, diese Tests immer auf dem aktuellen Stand zu halten und regelmäßig auszuführen. Bei umfangreicheren Anwendungen kann man den Team-Foundation-Server auch anweisen, nachts automatisch alle Test-Läufe zu starten.

Auswahl der Test-Fälle
Die Tests sind, wie oben schon angedeutet, immer nur so gut, wie die ausgewählten Test-Fälle. Bei der oben angeführten Test-Methode können wir die Richtigkeit des Ergebnisses nur dann garantieren, wenn die Werte 1, 2 und 3 addiert werden, und zwar genau in dieser Reihenfolge. Die Gewissenhaftigkeit würde nun verlangen, dass wir alle möglichen Kombinationen der Werte in a, b und c testen, also jede Zahl innerhalb des Wertebereiches in jeder Kombination von a, b und c. Mit dem gerade noch überschaubarem Wertebereich eines 32-Bit Integers wäre das mithilfe verschachtelter Schleifen möglich. Sinnvoller wäre es allerdings, wenige, aber gut geeignete Werte auszuwählen und diese zu testen. Welche Werte das sind, hängt von dem jeweiligen Anwendungsfall ab. In unserem Beispiel sollte man _normale _Anwendungsfälle wie die Addition positiver und negativer Zahlen simulieren, sowie _extreme _Fälle wie maximale negative oder positive Werte. Das Entwerfen geeigneter Testfälle erfordert ein wenig Erfahrung und den festen Willen, immer und immer wieder seinen eigenen Programmcode in Frage zu stellen und ihn dazu zu bringen, unerwartete Fehler zu produzieren.

Wie testet man eine Unit, die andere Units benutzt?
Die Idee von Unit-Tests ist, jede Unit für sich alleine, unabhängig von allen anderen Units zu testen. Wenn eine Unit andere Units benutzt, werden diese während des Tests üblicherweise durch sog. Mock-Objekte ersetzt. Die Mock-Objekte verhalten sich aus Sicht der zu testenden Unit wie die originalen Objekte, sind aber viel einfacher aufgebaut. Statt z.B. einen echten Datenbankzugriff durchzuführen könnte das Mock-Objekt einfach nur die für den konkreten Testfall benötigten Werte hardcodiert zurückliefern. Die Klassen für die Mock-Objekte werden meistens zeitgleich mit den zugehörigen Unit-Test erstellt.

Wie schafft man es, dass eine Unit während des Test die Mock-Objekte benutzt?
Dafür gibt es verschiedene Möglichkeiten. Am einfachsten ist es, wenn die Units die von ihnen benutzen Units nicht direkt, sondern immer über ein Interface ansprechen. Ein Mock-Objekt implementiert dann einfach dasselbe Interface wie das Original-Objekt und dadurch kann man die Objekte einfach austauschen. Dieses Prinzip nennt sich Dependency Injection. Verschiedene Möglichkeiten der praktischen Umsetzung sind in dem Wikipedia-Artikel beschrieben.

Warum der ganze Aufwand?
Um es kurz zu machen: Unit-Tests zu schreiben bedeutet, zusätzlich zu seinen eigenen Projekten Test-Projekte zu erstellen und zu warten. Und dies ist - wenn man es richtig macht - eine Menge Arbeit. Es handelt sich dabei aber nicht um zusätzliche Arbeit, wie man annehmen könnte, denn man erspart sich dadurch eine ständige und zeitraubende Fehlersuche und eine riesige Menge von unerwarten Bugreports, wenn die Software in der "echten Welt" eingesetzt wird. Einige weitere Vorteile, die man durch den Einsatz von Unit-Tests hat, sind u.a.:

  • Unit-Tests ermöglichen es, den Code jederzeit schnell zu erweitern und umzustrukturieren. Solange nach einem Refactoring die Unit-Tests durchlaufen, kann man weiterhin von der Funktionsfähigkeit der Software ausgehen.
  • Unit-Tests erlauben es, einen Fehler systematisch einzugrenzen und erleichtern daher das Debuggen.
  • Unit-Tests geben einem ein visuelles Feedback über den Zustand seines Software-Projektes und zeigen an, welche Programmteile funktionieren und welche noch nicht.
  • Unit-Tests dokumentieren, wie der Code verwendet wird, und sind (im Gegensatz zur manuellen Code-Dokumentation) immer auf dem aktuellen Stand.

**
Ist durch die Unit-Tests der komplette Softwaretest erledigt?**
Unit-Tests sind ein wichtiger Bestandteil der gesamten Testaktivitäten, jedoch nicht der einzige. Üblicherweise ist mindestens noch einen Integrationstest und Abnahmetest erforderlich. Der Integrationstest soll sicherstellen, dass die Units, die alle isoliert für sich getestet wurden, korrekt zusammenspielen. Der Abnahmetest ist wichtig, weil sich der Auftraggeber dadurch von der Korrektheit der Software überzeugen kann und die Verantwortung für die Korrektheit durch die Abnahme auf ihn übergeht.

Beim Integrationstest kommen insbesondere Fehler zum Vorschein, bei denen ein Programmierer die Spezifikation missverstanden oder sie aus anderen Gründen falsch in Testcode übersetzt hat. Dann funktioniert die Unit - nach dem Verständnis ihres Programmierers und dem Ergebnis des Units-Tests - in sich korrekt, arbeitet aber trotzdem nicht korrekt mit den anderen Units zusammen.

Beim Abnahmetest bzw. generell bei Tests durch den Auftraggeber kommen insbesondere Fehler zum Vorschein, die dadurch entstanden sind, dass Auftragnehmer und Auftraggeber die Spezifikation unterschiedlich interpretiert haben.

Das alles kann durch die besten Unit-Tests nicht geleistet bzw. nicht verhindert werden, weshalb die Unit-Test durch die genannten Tests ergänzt werden müssen. Darüber hinaus können je nach Situation weitere Tests nötig werden, z.B. GUI-Tests, Stress- und Lasttests, Tests zum Auffinden von Sicherheitslücken und im weiteren Sinne auch Korrektheitsbeweise.

Was ist eigentlich eine Unit?
Eine Unit ist die kleinste Codeeinheit, die isoliert getestet werden kann. Isoliert bedeutet in sich abgeschlossen und unabhängig von der Anwendung und allen anderen Units. An eine Unit werden typischerweise folgende Anforderungen gestellt: Eine Unit ...* füllt eine Quelltextdatei

  • ist eine Übersetzungseinheit (d.h. ein in sich abgeschlossenes Konstrukt der Programmiersprache)
  • ist klein, gemessen an der Zahl der Codezeilen
  • ist atomar, d.h. man erwägt normalerweise nicht, die Unit aufzuteilen

In objektorientierter Programmierung sind Units üblicherweise Klassen.

Getestet wird in aller Regel nur die öffentliche Schnittstelle der Unit.

Weeks of programming can save you hours of planning

Hallo MrSparkle,
ein guter Artikel zu einem sehr wichtigen Thema.
In der Praxis wird das auch heute noch all zu oft vernachlässigt.

Viele Grüße,
telfa

👍