.NET Unit Testing: Im Voraus ausgeben, um später zu sparen

Veröffentlicht: 2022-03-11

Es gibt oft viel Verwirrung und Zweifel in Bezug auf Unit-Tests, wenn es mit Stakeholdern und Kunden diskutiert wird. Komponententests klingen manchmal wie Zahnseide für ein Kind: „Ich putze bereits meine Zähne, warum muss ich das tun?“

Unit-Tests vorzuschlagen, klingt oft nach einer unnötigen Ausgabe für Leute, die ihre Testmethoden und Benutzerakzeptanztests für stark genug halten.

Unit Tests sind jedoch ein sehr mächtiges Werkzeug und einfacher als Sie vielleicht denken. In diesem Artikel werfen wir einen Blick auf Komponententests und die in DotNet verfügbaren Tools wie Microsoft.VisualStudio.TestTools und Moq .

Wir werden versuchen, eine einfache Klassenbibliothek zu erstellen, die den n-ten Term in der Fibonacci-Folge berechnet. Dazu möchten wir eine Klasse zum Berechnen von Fibonacci-Folgen erstellen, die von einer benutzerdefinierten Mathematikklasse abhängt, die die Zahlen addiert. Anschließend können wir das .NET Testing Framework verwenden, um sicherzustellen, dass unser Programm wie erwartet ausgeführt wird.

Was ist Unit-Testing?

Komponententests zerlegen das Programm in das kleinste Codebit, normalerweise auf Funktionsebene, und stellen sicher, dass die Funktion den erwarteten Wert zurückgibt. Durch die Verwendung eines Unit-Testing-Frameworks werden die Unit-Tests zu einer separaten Einheit, die dann automatisierte Tests für das Programm ausführen kann, während es erstellt wird.

 [TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }

Ein einfacher Komponententest mit der Methode „Arrange, Act, Assert“, die unsere Mathematikbibliothek korrekt 2 + 2 addieren kann.

Sobald die Einheitentests eingerichtet sind und eine Änderung am Code vorgenommen wird, um beispielsweise eine zusätzliche Bedingung zu berücksichtigen, die bei der ersten Entwicklung des Programms nicht bekannt war, zeigen die Einheitentests, ob alle Fälle mit den erwarteten Werten übereinstimmen Ausgabe durch die Funktion.

Unit-Tests sind keine Integrationstests. Es handelt sich nicht um End-to-End-Tests. Obwohl beides leistungsstarke Methoden sind, sollten sie in Verbindung mit Komponententests funktionieren – nicht als Ersatz.

Die Vorteile und der Zweck von Unit-Tests

Der am schwersten zu verstehende Vorteil von Komponententests, aber der wichtigste, ist die Möglichkeit, geänderten Code im laufenden Betrieb erneut zu testen. Der Grund, warum es so schwer zu verstehen ist, liegt darin, dass so viele Entwickler bei sich denken: „Ich werde diese Funktion nie wieder anfassen“ oder „Ich werde es einfach erneut testen, wenn ich fertig bin.“ Und die Interessengruppen denken in Form von: „Wenn dieses Stück bereits geschrieben ist, warum muss ich es dann erneut testen?“

Als jemand, der auf beiden Seiten des Entwicklungsspektrums war, habe ich beides gesagt. Der Entwickler in mir weiß, warum wir es erneut testen müssen.

Die Veränderungen, die wir täglich vornehmen, können enorme Auswirkungen haben. Zum Beispiel:

  • Berücksichtigt Ihr Schalter einen neu eingegebenen Wert richtig?
  • Weißt du, wie oft du diesen Schalter benutzt hast?
  • Haben Sie Groß-/Kleinschreibung nicht beachtende Zeichenfolgenvergleiche richtig berücksichtigt?
  • Suchen Sie ordnungsgemäß nach Nullen?
  • Wird eine Throw-Ausnahme wie erwartet behandelt?

Unit-Tests nehmen diese Fragen auf und speichern sie in Code und einem Prozess , um sicherzustellen, dass diese Fragen immer beantwortet werden. Komponententests können vor einem Build ausgeführt werden, um sicherzustellen, dass Sie keine neuen Fehler eingeführt haben. Da Komponententests atomar konzipiert sind, werden sie sehr schnell ausgeführt, normalerweise weniger als 10 Millisekunden pro Test. Selbst bei einer sehr großen Anwendung kann eine vollständige Testsuite in weniger als einer Stunde durchgeführt werden. Kann Ihr UAT-Prozess dem entsprechen?

Beispiel für eine Namenskonvention, die eingerichtet wurde, um einfach nach einer Klasse oder Methode innerhalb einer zu testenden Klasse zu suchen.
Abgesehen von Fibonacci_GetNthTerm_Input2_AssertResult1 , das der erste Lauf ist und die Einrichtungszeit beinhaltet, laufen alle Komponententests unter 5 ms. Meine Namenskonvention hier ist so eingerichtet, dass ich einfach nach einer Klasse oder Methode innerhalb einer Klasse suchen kann, die ich testen möchte

Als Entwickler hört sich das vielleicht nach mehr Arbeit für Sie an. Ja, Sie haben die Gewissheit, dass der Code, den Sie freigeben, gut ist. Unit-Tests bieten Ihnen aber auch die Möglichkeit zu sehen, wo Ihr Design schwach ist. Schreiben Sie die gleichen Komponententests für zwei Codeteile? Sollten sie sich stattdessen auf einem Stück Code befinden?

Wenn Sie Ihren Code selbst komponententestbar machen, können Sie Ihr Design verbessern. Und für die meisten Entwickler, die noch nie Unit-Tests durchgeführt haben oder sich nicht so viel Zeit nehmen, um das Design vor dem Codieren zu überdenken, können Sie erkennen, wie sehr sich Ihr Design verbessert, wenn Sie es für Unit-Tests vorbereiten.

Ist Ihre Code Unit testbar?

Neben DRY haben wir auch andere Überlegungen.

Versuchen Ihre Methoden oder Funktionen zu viel zu leisten?

Wenn Sie übermäßig komplexe Komponententests schreiben müssen, die länger als erwartet ausgeführt werden, ist Ihre Methode möglicherweise zu kompliziert und als mehrere Methoden besser geeignet.

Setzen Sie Dependency Injection richtig ein?

Wenn Ihre zu testende Methode eine andere Klasse oder Funktion erfordert, nennen wir dies eine Abhängigkeit. Beim Komponententest ist es uns egal, was die Abhängigkeit unter der Haube macht; für den Zweck des zu testenden Verfahrens ist es eine Blackbox. Die Abhängigkeit verfügt über einen eigenen Satz von Komponententests, die bestimmen, ob ihr Verhalten ordnungsgemäß funktioniert.

Als Tester möchten Sie diese Abhängigkeit simulieren und ihr mitteilen, welche Werte in bestimmten Fällen zurückgegeben werden sollen. Dadurch erhalten Sie eine bessere Kontrolle über Ihre Testfälle. Dazu müssen Sie eine Dummy- (oder, wie wir später sehen werden, verspottete) Version dieser Abhängigkeit einfügen.

Interagieren Ihre Komponenten so miteinander, wie Sie es erwarten?

Nachdem Sie Ihre Abhängigkeiten und Ihre Abhängigkeitsinjektion ausgearbeitet haben, stellen Sie möglicherweise fest, dass Sie zyklische Abhängigkeiten in Ihren Code eingeführt haben. Wenn Klasse A von Klasse B abhängt, die wiederum von Klasse A abhängt, sollten Sie Ihr Design überdenken.

Die Schönheit der Abhängigkeitsinjektion

Betrachten wir unser Fibonacci-Beispiel. Ihr Chef teilt Ihnen mit, dass er eine neue Klasse hat, die effizienter und genauer ist als der aktuelle Add-Operator, der in C# verfügbar ist.

Während dieses spezielle Beispiel in der realen Welt nicht sehr wahrscheinlich ist, sehen wir analoge Beispiele in anderen Komponenten wie Authentifizierung, Objektzuordnung und so ziemlich jedem algorithmischen Prozess. Für die Zwecke dieses Artikels wollen wir einfach so tun, als wäre die neue Add-Funktion Ihres Clients die neueste und beste seit Erfindung des Computers.

Daher überreicht Ihnen Ihr Chef eine Blackbox-Bibliothek mit einer einzelnen Klasse Math und in dieser Klasse einer einzelnen Funktion Add . Ihre Aufgabe, einen Fibonacci-Rechner zu implementieren, sieht wahrscheinlich so aus:

 public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }

Das ist nicht furchtbar. Sie instanziieren eine neue Math -Klasse und verwenden diese, um die beiden vorherigen Terme hinzuzufügen, um den nächsten zu erhalten. Sie führen diese Methode durch Ihre normale Testbatterie, berechnen bis zu 100 Termen, berechnen den 1000. Term, den 10.000. Term und so weiter, bis Sie zufrieden sind, dass Ihre Methodik gut funktioniert. Irgendwann in der Zukunft beschwert sich dann ein Benutzer, dass der 501. Begriff nicht wie erwartet funktioniert. Sie verbringen den Abend damit, Ihren Code durchzusehen und herauszufinden, warum dieser Sonderfall nicht funktioniert. Du beginnst misstrauisch zu werden, dass der neueste und beste Math -Kurs nicht ganz so toll ist, wie dein Chef denkt. Aber es ist eine Black Box und das kann man nicht wirklich beweisen – man kommt innerlich in eine Sackgasse.

Das Problem hier ist, dass die Abhängigkeit Math nicht in Ihren Fibonacci-Rechner eingefügt wird. Daher verlassen Sie sich bei Ihren Tests immer auf die vorhandenen, ungetesteten und unbekannten Ergebnisse aus Math , um Fibonacci gegeneinander zu testen. Wenn es ein Problem mit Math gibt, ist Fibonacci immer falsch (ohne einen Sonderfall für den 501. Term zu codieren).

Die Idee, dieses Problem zu beheben, besteht darin, die Math -Klasse in Ihren Fibonacci-Rechner einzufügen. Aber noch besser ist es, eine Schnittstelle für die Math -Klasse zu erstellen, die die öffentlichen Methoden (in unserem Fall Add ) definiert, und die Schnittstelle in unserer Math -Klasse zu implementieren.

 public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }

Anstatt die Math -Klasse in Fibonacci einzufügen, können wir die IMath Schnittstelle in Fibonacci einfügen. Der Vorteil hier ist, dass wir unsere eigene OurMath -Klasse definieren könnten, von der wir wissen, dass sie genau ist, und unseren Rechner damit testen. Noch besser, mit Moq können wir einfach definieren, was Math.Add zurückgibt. Wir können eine Reihe von Summen definieren oder Math.Add einfach anweisen, x + y zurückzugeben.

 private IMath _math; public Fibonacci(IMath math) { _math = math; }

Fügen Sie die IMath-Schnittstelle in die Fibonacci-Klasse ein

 //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);

Verwenden von Moq, um zu definieren, was Math.Add zurückgibt.

Jetzt haben wir eine erprobte und wahre (nun, wenn dieser +-Operator in C# falsch ist, haben wir größere Probleme) Methode zum Addieren von zwei Zahlen. Mit unserem neuen IMath können wir einen Komponententest für unseren 501. Begriff codieren und sehen, ob wir unsere Implementierung vermasselt haben oder ob die benutzerdefinierte Math -Klasse etwas mehr Arbeit erfordert.

Lassen Sie eine Methode nicht versuchen, zu viel zu tun

Dieses Beispiel weist auch auf die Idee einer Methode hin, die zu viel leistet. Sicher, die Addition ist eine ziemlich einfache Operation, ohne dass ihre Funktionalität von unserer GetNthTerm Methode abstrahiert werden muss. Aber was wäre, wenn die Operation etwas komplizierter wäre? Anstatt einer Hinzufügung war es vielleicht eine Modellvalidierung, ein Anruf bei einer Fabrik, um ein Objekt zu erhalten, an dem gearbeitet werden soll, oder das Sammeln zusätzlicher benötigter Daten aus einem Repository.

Die meisten Entwickler werden versuchen, an der Idee festzuhalten, dass eine Methode nur einen Zweck hat. Bei Unit-Tests versuchen wir, uns an das Prinzip zu halten, dass Unit-Tests auf atomare Methoden angewendet werden sollten, und indem wir zu viele Operationen in eine Methode einführen, machen wir sie nicht testbar. Wir können oft ein Problem erzeugen, bei dem wir so viele Tests schreiben müssen, um unsere Funktion richtig zu testen.

Jeder Parameter, den wir einer Methode hinzufügen, erhöht die Anzahl der Tests, die wir schreiben müssen, exponentiell entsprechend der Komplexität des Parameters. Wenn Sie Ihrer Logik einen booleschen Wert hinzufügen, müssen Sie die Anzahl der zu schreibenden Tests verdoppeln, da Sie jetzt die wahren und falschen Fälle zusammen mit Ihren aktuellen Tests überprüfen müssen. Im Fall der Modellvalidierung kann die Komplexität unserer Unit-Tests sehr schnell zunehmen.

Diagramm der erhöhten Tests, die erforderlich sind, wenn der Logik ein boolescher Wert hinzugefügt wird.

Wir sind alle schuldig, einer Methode ein kleines Extra hinzuzufügen. Aber diese größeren, komplexeren Methoden machen zu viele Unit-Tests erforderlich. Und beim Schreiben der Unit-Tests wird schnell deutlich, dass die Methode zu viel zu leisten versucht. Wenn Sie das Gefühl haben, dass Sie versuchen, zu viele mögliche Ergebnisse Ihrer Eingabeparameter zu testen, sollten Sie bedenken, dass Ihre Methode in eine Reihe kleinerer Ergebnisse unterteilt werden muss.

Wiederholen Sie sich nicht

Einer unserer Lieblingspächter der Programmierung. Dieser sollte ziemlich geradlinig sein. Wenn Sie dieselben Tests mehr als einmal schreiben, haben Sie mehr als einmal Code eingeführt. Es kann für Sie von Vorteil sein, diese Arbeit in eine gemeinsame Klasse umzugestalten, auf die beide Instanzen zugreifen können, die Sie verwenden möchten.

Welche Unit-Testing-Tools sind verfügbar?

DotNet bietet uns eine sehr leistungsstarke Unit-Testing-Plattform out of the box. Damit können Sie die sogenannte Arrange-, Act-, Assert-Methodik implementieren. Sie arrangieren Ihre anfänglichen Überlegungen, reagieren mit Ihrer zu testenden Methode auf diese Bedingungen und behaupten dann, dass etwas passiert ist. Sie können alles behaupten, was dieses Tool noch leistungsfähiger macht. Sie können behaupten, dass eine Methode eine bestimmte Anzahl von Malen aufgerufen wurde, dass die Methode einen bestimmten Wert zurückgegeben hat, dass ein bestimmter Ausnahmetyp ausgelöst wurde oder was Ihnen sonst noch einfällt. Für diejenigen, die nach einem fortgeschritteneren Framework suchen, sind NUnit und sein Java-Pendant JUnit praktikable Optionen.

 [TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }

Testen, ob unsere Fibonacci-Methode negative Zahlen verarbeitet, indem sie eine Ausnahme auslöst. Komponententests können überprüfen, ob die Ausnahme ausgelöst wurde.

Um die Abhängigkeitsinjektion zu handhaben, existieren sowohl Ninject als auch Unity auf der DotNet-Plattform. Es gibt kaum einen Unterschied zwischen den beiden, und es stellt sich die Frage, ob Sie Konfigurationen mit Fluent Syntax oder XML Configuration verwalten möchten.

Zur Simulation der Abhängigkeiten empfehle ich Moq. Moq kann eine Herausforderung sein, um Ihre Hände zu bekommen, aber das Wesentliche ist, dass Sie eine verspottete Version Ihrer Abhängigkeiten erstellen. Anschließend teilen Sie der Abhängigkeit mit, was unter bestimmten Bedingungen zurückgegeben werden soll. Wenn Sie beispielsweise eine Methode namens Square(int x) haben, die die ganze Zahl quadriert, können Sie ihr mitteilen, dass x = 2 4 zurückgibt. Sie können ihr auch sagen, dass sie x^2 für jede ganze Zahl zurückgeben soll. Oder Sie könnten sagen, dass es 5 zurückgeben soll, wenn x = 2. Warum würden Sie den letzten Fall ausführen? Für den Fall, dass die Methode unter der Rolle des Tests darin besteht, die Antwort aus der Abhängigkeit zu validieren, möchten Sie möglicherweise die Rückgabe ungültiger Antworten erzwingen, um sicherzustellen, dass Sie den Fehler richtig abfangen.

 [TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }

Verwenden von Moq, um der verspotteten IMath Schnittstelle mitzuteilen, wie sie mit Add under test umgehen soll. Sie können explizite Fälle mit It.Is oder einen Bereich mit It.IsInRange .

Unit Testing Frameworks für DotNet

Microsoft Unit Testing Framework

Das Microsoft Unit Testing Framework ist die sofort einsatzbereite Unit-Testing-Lösung von Microsoft und in Visual Studio enthalten. Da es mit VS geliefert wird, lässt es sich gut darin integrieren. Wenn Sie ein Projekt beginnen, werden Sie von Visual Studio gefragt, ob Sie neben Ihrer Anwendung eine Komponententestbibliothek erstellen möchten.

Das Microsoft Unit Testing Framework enthält außerdem eine Reihe von Tools, mit denen Sie Ihre Testverfahren besser analysieren können. Da es sich im Besitz von Microsoft befindet und von Microsoft geschrieben wird, gibt es ein gewisses Gefühl der Stabilität in seiner Existenz für die Zukunft.

Aber wenn Sie mit Microsoft-Tools arbeiten, bekommen Sie, was sie Ihnen bieten. Das Microsoft Unit Testing Framework kann umständlich zu integrieren sein.

NUnit

Der größte Vorteil für mich bei der Verwendung von NUnit sind parametrisierte Tests. In unserem obigen Fibonacci-Beispiel können wir eine Reihe von Testfällen eingeben und sicherstellen, dass diese Ergebnisse wahr sind. Und im Fall unseres 501. Problems können wir jederzeit einen neuen Parametersatz hinzufügen, um sicherzustellen, dass der Test immer ohne die Notwendigkeit einer neuen Testmethode ausgeführt wird.

Der größte Nachteil von NUnit ist die Integration in Visual Studio. Es fehlt der Schnickschnack, der mit der Microsoft-Version geliefert wird, und bedeutet, dass Sie Ihr eigenes Toolset herunterladen müssen.

xUnit.Net

xUnit ist in C# sehr beliebt, weil es sich so gut in das bestehende .NET-Ökosystem integrieren lässt. Nuget verfügt über viele Erweiterungen von xUnit. Es lässt sich auch gut in Team Foundation Server integrieren, obwohl ich nicht sicher bin, wie viele .NET-Entwickler TFS immer noch über verschiedene Git-Implementierungen verwenden.

Auf der anderen Seite beschweren sich viele Benutzer, dass die Dokumentation von xUnit etwas fehlt. Für neue Benutzer von Unit-Tests kann dies massive Kopfschmerzen verursachen. Darüber hinaus machen die Erweiterbarkeit und Anpassungsfähigkeit von xUnit die Lernkurve etwas steiler als bei NUnit oder dem Unit Testing Framework von Microsoft.

Testgetriebenes Design/Entwicklung

Test Driven Design/Development (TDD) ist ein etwas fortgeschritteneres Thema, das einen eigenen Beitrag verdient. Trotzdem wollte ich eine Einführung geben.

Die Idee ist, mit Ihren Unit-Tests zu beginnen und Ihren Unit-Tests mitzuteilen, was richtig ist. Dann können Sie Ihren Code um diese Tests herum schreiben. Theoretisch klingt das Konzept einfach, aber in der Praxis ist es sehr schwierig, Ihr Gehirn darauf zu trainieren, rückwärts über die Anwendung nachzudenken. Aber der Ansatz hat den eingebauten Vorteil, dass Sie Ihre Komponententests nicht nachträglich schreiben müssen. Dies führt zu weniger Refactoring, Umschreiben und Klassenverwirrung.

TDD war in den letzten Jahren ein bisschen ein Schlagwort, aber die Akzeptanz war langsam. Seine konzeptionelle Natur ist für die Beteiligten verwirrend, was die Genehmigung erschwert. Aber als Entwickler ermutige ich Sie, selbst eine kleine Anwendung mit dem TDD-Ansatz zu schreiben, um sich an den Prozess zu gewöhnen.

Warum Sie nicht zu viele Unit-Tests haben können

Unit-Tests sind eines der leistungsstärksten Testwerkzeuge, die Entwicklern zur Verfügung stehen. Es reicht in keiner Weise für einen vollständigen Test Ihrer Anwendung aus, aber seine Vorteile bei Regressionstests, Codedesign und Dokumentation des Zwecks sind unübertroffen.

Es gibt keine Möglichkeit, zu viele Unit-Tests zu schreiben. Jeder Grenzfall kann später große Probleme in Ihrer Software vorschlagen. Das Gedenken an gefundene Fehler als Einheitentests kann sicherstellen, dass sich diese Fehler bei späteren Codeänderungen nicht wieder in Ihre Software einschleichen. Während Sie das Vorabbudget Ihres Projekts um 10-20 % erhöhen können, könnten Sie viel mehr als das bei Schulungen, Fehlerbehebungen und Dokumentation einsparen.

Das in diesem Artikel verwendete Bitbucket-Repository finden Sie hier.