Komponententests, wie man testbaren Code schreibt und warum es wichtig ist
Veröffentlicht: 2022-03-11Unit Testing ist ein unverzichtbares Instrument im Werkzeugkasten eines jeden ernsthaften Softwareentwicklers. Es kann jedoch manchmal ziemlich schwierig sein, einen guten Komponententest für einen bestimmten Codeabschnitt zu schreiben. Entwickler, die Schwierigkeiten haben, ihren eigenen Code oder den von jemand anderem zu testen, denken oft, dass ihre Schwierigkeiten durch einen Mangel an grundlegendem Testwissen oder geheimen Unit-Test-Techniken verursacht werden.
In diesem Unit-Testing-Tutorial möchte ich zeigen, dass Unit-Tests recht einfach sind. Die wirklichen Probleme, die Unit-Tests erschweren und teure Komplexität einführen, sind das Ergebnis von schlecht entworfenem, nicht testbarem Code. Wir werden diskutieren, was das Testen von Code erschwert, welche Anti-Patterns und schlechte Praktiken wir vermeiden sollten, um die Testbarkeit zu verbessern, und welche anderen Vorteile wir durch das Schreiben von testbarem Code erzielen können. Wir werden sehen, dass es beim Schreiben von Unit-Tests und Generieren von testbarem Code nicht nur darum geht, das Testen weniger mühsam zu machen, sondern auch darum, den Code selbst robuster und einfacher zu warten.
Was ist Unit-Testing?
Im Wesentlichen ist ein Komponententest eine Methode, die einen kleinen Teil unserer Anwendung instanziiert und ihr Verhalten unabhängig von anderen Teilen überprüft. Ein typischer Komponententest besteht aus drei Phasen: Zuerst initialisiert er einen kleinen Teil einer Anwendung, die getestet werden soll (auch bekannt als das zu testende System oder SUT), dann wendet er einen Stimulus auf das zu testende System an (normalerweise durch Aufrufen von a Methode darauf), und schließlich beobachtet es das resultierende Verhalten. Wenn das beobachtete Verhalten den Erwartungen entspricht, ist der Komponententest bestanden, andernfalls schlägt er fehl, was darauf hinweist, dass irgendwo im getesteten System ein Problem vorliegt. Diese drei Einheitentestphasen werden auch als Arrange, Act und Assert oder einfach AAA bezeichnet.
Ein Komponententest kann verschiedene Verhaltensaspekte des getesteten Systems überprüfen, aber höchstwahrscheinlich fällt er in eine der folgenden zwei Kategorien: zustandsbasiert oder interaktionsbasiert . Die Überprüfung, ob das zu testende System korrekte Ergebnisse liefert oder ob sein resultierender Zustand korrekt ist, wird als zustandsbasierter Komponententest bezeichnet, während die Überprüfung, ob bestimmte Methoden ordnungsgemäß aufgerufen werden, als interaktionsbasierter Komponententest bezeichnet wird.
Stellen Sie sich als Metapher für das richtige Testen von Softwareeinheiten einen verrückten Wissenschaftler vor, der eine übernatürliche Chimäre mit Froschschenkeln, Krakententakeln, Vogelflügeln und einem Hundekopf bauen möchte. (Diese Metapher kommt dem, was Programmierer tatsächlich bei der Arbeit tun, ziemlich nahe). Wie würde dieser Wissenschaftler sicherstellen, dass jedes Teil (oder jede Einheit), die er ausgewählt hat, tatsächlich funktioniert? Nun, er kann, sagen wir, ein einzelnes Froschbein nehmen, einen elektrischen Stimulus darauf anwenden und auf richtige Muskelkontraktion prüfen. Was er tut, sind im Wesentlichen die gleichen Arrange-Act-Assert-Schritte des Komponententests; Der einzige Unterschied besteht darin, dass sich Einheit in diesem Fall auf ein physisches Objekt bezieht, nicht auf ein abstraktes Objekt, aus dem wir unsere Programme erstellen.
Ich werde C# für alle Beispiele in diesem Artikel verwenden, aber die beschriebenen Konzepte gelten für alle objektorientierten Programmiersprachen.
Ein Beispiel für einen einfachen Unit-Test könnte so aussehen:
[TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }
Unit-Test vs. Integrationstest
Ein weiterer wichtiger Punkt ist der Unterschied zwischen Komponententests und Integrationstests.
Der Zweck eines Unit-Tests im Software-Engineering besteht darin, das Verhalten eines relativ kleinen Stücks Software unabhängig von anderen Teilen zu überprüfen. Unit-Tests haben einen engen Umfang und ermöglichen es uns, alle Fälle abzudecken und sicherzustellen, dass jeder einzelne Teil korrekt funktioniert.
Andererseits zeigen Integrationstests, dass verschiedene Teile eines Systems in der realen Umgebung zusammenarbeiten . Sie validieren komplexe Szenarien (wir können uns Integrationstests als einen Benutzer vorstellen, der eine Operation auf hoher Ebene in unserem System ausführt) und erfordern normalerweise das Vorhandensein externer Ressourcen wie Datenbanken oder Webserver.
Gehen wir zurück zu unserer Metapher des verrückten Wissenschaftlers und nehmen wir an, dass er alle Teile der Chimäre erfolgreich kombiniert hat. Er möchte einen Integrationstest der resultierenden Kreatur durchführen, um sicherzustellen, dass sie, sagen wir, auf verschiedenen Arten von Gelände laufen kann. Zunächst muss der Wissenschaftler eine Umgebung emulieren, auf der die Kreatur laufen kann. Dann wirft er die Kreatur in diese Umgebung und stößt sie mit einem Stock an, um zu beobachten, ob sie wie vorgesehen geht und sich bewegt. Nach Abschluss eines Tests räumt der verrückte Wissenschaftler all den Schmutz, Sand und die Steine auf, die jetzt in seinem schönen Labor verstreut sind.
Beachten Sie den signifikanten Unterschied zwischen Unit- und Integrationstests: Ein Unit-Test überprüft das Verhalten eines kleinen Teils der Anwendung, isoliert von der Umgebung und anderen Teilen, und ist recht einfach zu implementieren, während ein Integrationstest Interaktionen zwischen verschiedenen Komponenten abdeckt realitätsnahen Umgebung und erfordert mehr Aufwand, einschließlich zusätzlicher Auf- und Abbauphasen.
Eine vernünftige Kombination aus Einheiten- und Integrationstests stellt sicher, dass jede einzelne Einheit unabhängig von anderen korrekt funktioniert und dass alle diese Einheiten gut funktionieren, wenn sie integriert sind, was uns ein hohes Maß an Vertrauen gibt, dass das gesamte System wie erwartet funktioniert.
Wir müssen jedoch daran denken, immer zu identifizieren, welche Art von Test wir implementieren: einen Unit- oder einen Integrationstest. Der Unterschied kann manchmal täuschen. Wenn wir glauben, dass wir einen Komponententest schreiben, um einen subtilen Grenzfall in einer Geschäftslogikklasse zu überprüfen, und feststellen, dass externe Ressourcen wie Webdienste oder Datenbanken vorhanden sein müssen, stimmt etwas nicht – im Wesentlichen verwenden wir einen Vorschlaghammer eine Nuss knacken. Und das bedeutet schlechtes Design.
Was macht einen guten Unit-Test aus?
Bevor wir in den Hauptteil dieses Tutorials eintauchen und Unit-Tests schreiben, lassen Sie uns schnell die Eigenschaften eines guten Unit-Tests besprechen. Unit-Testing-Prinzipien verlangen, dass ein guter Test:
Einfach zu schreiben. Entwickler schreiben in der Regel viele Unit-Tests, um verschiedene Fälle und Aspekte des Verhaltens der Anwendung abzudecken, daher sollte es einfach sein, all diese Testroutinen ohne enormen Aufwand zu codieren.
Lesbar. Die Absicht eines Unit-Tests sollte klar sein. Ein guter Komponententest erzählt eine Geschichte über einige Verhaltensaspekte unserer Anwendung, daher sollte es leicht zu verstehen sein, welches Szenario getestet wird, und – falls der Test fehlschlägt – leicht zu erkennen sein, wie das Problem angegangen werden kann. Mit einem guten Komponententest können wir einen Fehler beheben, ohne den Code tatsächlich zu debuggen!
Zuverlässig. Komponententests sollten nur dann fehlschlagen, wenn das zu testende System einen Fehler aufweist. Das scheint ziemlich offensichtlich, aber Programmierer stoßen oft auf ein Problem, wenn ihre Tests fehlschlagen, selbst wenn keine Fehler eingeführt wurden. Beispielsweise können Tests erfolgreich ausgeführt werden, wenn sie einzeln ausgeführt werden, aber fehlschlagen, wenn die gesamte Testsuite ausgeführt wird, oder unsere Entwicklungsmaschine weitergeben und auf dem Continuous-Integration-Server fehlschlagen. Diese Situationen weisen auf einen Konstruktionsfehler hin. Gute Unit-Tests sollten reproduzierbar und unabhängig von äußeren Faktoren wie Umgebung oder Running Order sein.
Schnell. Entwickler schreiben Komponententests, damit sie sie wiederholt ausführen und überprüfen können, ob keine Fehler eingeführt wurden. Wenn Komponententests langsam sind, überspringen Entwickler eher die Ausführung auf ihren eigenen Computern. Ein langsamer Test macht keinen signifikanten Unterschied; Fügen Sie tausend weitere hinzu und wir müssen sicherlich eine Weile warten. Langsame Komponententests können auch darauf hinweisen, dass entweder das zu testende System oder der Test selbst mit externen Systemen interagiert, wodurch es umgebungsabhängig wird.
Wirklich Einheit, nicht Integration. Wie wir bereits besprochen haben, haben Unit- und Integrationstests unterschiedliche Zwecke. Sowohl der Unit-Test als auch das zu testende System sollten nicht auf Netzwerkressourcen, Datenbanken, Dateisystem usw. zugreifen, um den Einfluss externer Faktoren zu eliminieren.
Das war's – es gibt keine Geheimnisse beim Schreiben von Unit-Tests . Es gibt jedoch einige Techniken, mit denen wir testbaren Code schreiben können.
Testbarer und nicht testbarer Code
Mancher Code ist so geschrieben, dass es schwierig oder sogar unmöglich ist, einen guten Komponententest dafür zu schreiben. Was macht es also schwierig, Code zu testen? Sehen wir uns einige Anti-Patterns, Code Smells und schlechte Praktiken an, die wir beim Schreiben von testbarem Code vermeiden sollten.
Vergiftung der Codebasis mit nicht-deterministischen Faktoren
Beginnen wir mit einem einfachen Beispiel. Stellen Sie sich vor, wir schreiben ein Programm für einen Smart-Home-Mikrocontroller, und eine der Anforderungen besteht darin, das Licht im Hinterhof automatisch einzuschalten, wenn dort abends oder nachts eine Bewegung erkannt wird. Wir haben von unten nach oben begonnen, indem wir eine Methode implementiert haben, die eine Zeichenfolgendarstellung der ungefähren Tageszeit zurückgibt („Nacht“, „Morgen“, „Nachmittag“ oder „Abend“):
public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }
Im Wesentlichen liest diese Methode die aktuelle Systemzeit und gibt ein Ergebnis basierend auf diesem Wert zurück. Also, was ist falsch an diesem Code?
Wenn wir aus der Unit-Test-Perspektive darüber nachdenken, werden wir sehen, dass es nicht möglich ist, einen richtigen zustandsbasierten Unit-Test für diese Methode zu schreiben. DateTime.Now
ist im Wesentlichen eine versteckte Eingabe, die sich wahrscheinlich während der Programmausführung oder zwischen Testläufen ändern wird. Daher führen nachfolgende Aufrufe zu unterschiedlichen Ergebnissen.
Ein solches nicht deterministisches Verhalten macht es unmöglich, die interne Logik der Methode GetTimeOfDay()
zu testen, ohne Datum und Uhrzeit des Systems tatsächlich zu ändern. Schauen wir uns an, wie ein solcher Test implementiert werden müsste:
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }
Tests wie dieser würden viele der zuvor besprochenen Regeln verletzen. Es wäre teuer zu schreiben (aufgrund der nicht trivialen Setup- und Teardown-Logik), unzuverlässig (es kann fehlschlagen, selbst wenn es keine Fehler im zu testenden System gibt, beispielsweise aufgrund von Systemberechtigungsproblemen) und dies wird nicht garantiert schnell rennen. Und schließlich wäre dieser Test eigentlich kein Unit-Test – er wäre etwas zwischen einem Unit- und einem Integrationstest, weil er vorgibt, einen einfachen Randfall zu testen, aber eine Umgebung erfordert, die auf eine bestimmte Weise eingerichtet ist. Das Ergebnis ist die Mühe nicht wert, oder?
Es stellt sich heraus, dass all diese Testbarkeitsprobleme durch die minderwertige GetTimeOfDay()
API verursacht werden. In ihrer jetzigen Form leidet diese Methode unter mehreren Problemen:
Sie ist eng an die konkrete Datenquelle gekoppelt. Es ist nicht möglich, diese Methode für die Verarbeitung von Datum und Uhrzeit wiederzuverwenden, die aus anderen Quellen abgerufen oder als Argument übergeben werden. Die Methode funktioniert nur mit dem Datum und der Uhrzeit der jeweiligen Maschine, die den Code ausführt. Enge Kopplung ist die primäre Wurzel der meisten Testbarkeitsprobleme.
Es verstößt gegen das Single-Responsibility-Prinzip (SRP). Die Methode hat mehrere Verantwortlichkeiten; es konsumiert die Informationen und verarbeitet sie auch. Ein weiterer Indikator für eine SRP-Verletzung ist, wenn eine einzelne Klasse oder Methode mehr als einen Grund für eine Änderung hat. Aus dieser Perspektive könnte die Methode
GetTimeOfDay()
geändert werden, entweder aufgrund interner Logikanpassungen oder weil die Quelle für Datum und Uhrzeit geändert werden sollte.Es lügt über die Informationen, die erforderlich sind, um seine Arbeit zu erledigen. Entwickler müssen jede Zeile des eigentlichen Quellcodes lesen, um zu verstehen, welche versteckten Eingaben verwendet werden und woher sie kommen. Die Methodensignatur allein reicht nicht aus, um das Verhalten der Methode zu verstehen.
Es ist schwer vorherzusagen und zu warten. Das Verhalten einer Methode, die von einem änderbaren globalen Zustand abhängt, kann nicht vorhergesagt werden, indem lediglich der Quellcode gelesen wird; Es ist notwendig, seinen aktuellen Wert zusammen mit der gesamten Abfolge von Ereignissen zu berücksichtigen, die ihn früher verändert haben könnten. In einer realen Anwendung wird der Versuch, all diese Dinge zu enträtseln, zu echten Kopfschmerzen.
Nachdem wir die API überprüft haben, können wir sie endlich beheben! Glücklicherweise ist dies viel einfacher, als alle seine Fehler zu diskutieren – wir müssen nur die eng miteinander verbundenen Bedenken auflösen.
Reparieren der API: Einführung eines Methodenarguments
Der naheliegendste und einfachste Weg, die API zu reparieren, ist die Einführung eines Methodenarguments:
public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }
Jetzt erfordert die Methode, dass der Aufrufer ein DateTime
Argument bereitstellt, anstatt heimlich selbst nach diesen Informationen zu suchen. Aus Sicht der Unit-Tests ist das großartig; Die Methode ist jetzt deterministisch (d. h. ihr Rückgabewert hängt vollständig von der Eingabe ab), sodass das zustandsbasierte Testen so einfach ist wie das Übergeben eines DateTime
Werts und das Überprüfen des Ergebnisses:
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }
Beachten Sie, dass dieser einfache Refactor auch alle zuvor besprochenen API-Probleme (enge Kopplung, SRP-Verletzung, unklare und schwer verständliche API) gelöst hat, indem eine klare Naht zwischen den zu verarbeitenden Daten und der Art und Weise der Verarbeitung eingeführt wurde.
Ausgezeichnet – die Methode ist testbar, aber wie sieht es mit ihren Kunden aus? Jetzt liegt es in der Verantwortung des Aufrufers , der GetTimeOfDay(DateTime dateTime)
-Methode Datum und Uhrzeit bereitzustellen, was bedeutet, dass sie nicht mehr getestet werden können, wenn wir nicht genügend Aufmerksamkeit schenken. Schauen wir uns an, wie wir damit umgehen können.
Beheben der Client-API: Abhängigkeitsinjektion
Angenommen, wir arbeiten weiter am Smart-Home-System und implementieren den folgenden Client der GetTimeOfDay(DateTime dateTime)
-Methode – den oben erwähnten Smart-Home-Mikrocontroller-Code, der dafür verantwortlich ist, das Licht basierend auf der Tageszeit und der Bewegungserkennung ein- oder auszuschalten :
public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }
Autsch! Wir haben die gleiche Art von verstecktem DateTime.Now
Eingabeproblem – der einzige Unterschied besteht darin, dass es sich auf einer etwas höheren Abstraktionsebene befindet. Um dieses Problem zu lösen, können wir ein weiteres Argument einführen, das wiederum die Verantwortung für die Bereitstellung eines DateTime
Werts an den Aufrufer einer neuen Methode mit der Signatur ActuateLights(bool motionDetected, DateTime dateTime)
. Aber anstatt das Problem noch einmal eine Ebene höher in der Aufrufliste zu verschieben, verwenden wir eine andere Technik, die es uns ermöglicht, sowohl die ActuateLights(bool motionDetected)
-Methode als auch ihre Clients testbar zu halten: Inversion of Control oder IoC.
Inversion of Control ist eine einfache, aber äußerst nützliche Technik zum Entkoppeln von Code und insbesondere zum Testen von Einheiten. (Schließlich ist es wichtig, die Dinge lose gekoppelt zu halten, um sie unabhängig voneinander analysieren zu können.) Der Schlüsselpunkt von IoC besteht darin, den Entscheidungscode ( wann etwas zu tun ist) vom Aktionscode ( was zu tun ist, wenn etwas passiert) zu trennen ). Diese Technik erhöht die Flexibilität, macht unseren Code modularer und reduziert die Kopplung zwischen Komponenten.
Inversion of Control kann auf verschiedene Weise implementiert werden; Werfen wir einen Blick auf ein bestimmtes Beispiel – Dependency Injection mit einem Konstruktor – und wie es beim Erstellen einer testbaren SmartHomeController
-API helfen kann.
Lassen Sie uns zunächst eine IDateTimeProvider
-Schnittstelle erstellen, die eine Methodensignatur zum Abrufen von Datum und Uhrzeit enthält:
public interface IDateTimeProvider { DateTime GetDateTime(); }
Lassen Sie dann SmartHomeController
auf eine IDateTimeProvider
-Implementierung verweisen und delegieren Sie ihm die Verantwortung für das Abrufen von Datum und Uhrzeit:

public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }
Jetzt können wir sehen, warum Inversion of Control so genannt wird: Die Kontrolle darüber, welcher Mechanismus zum Lesen von Datum und Uhrzeit verwendet werden soll, wurde invertiert und gehört jetzt dem Client von SmartHomeController
, nicht SmartHomeController
selbst. Dabei hängt die Ausführung der Methode ActuateLights(bool motionDetected)
vollständig von zwei Dingen ab, die leicht von außen verwaltet werden können: dem Argument motionDetected
und einer konkreten Implementierung von IDateTimeProvider
, die an einen SmartHomeController
-Konstruktor übergeben wird.
Warum ist dies für Unit-Tests von Bedeutung? Dies bedeutet, dass verschiedene IDateTimeProvider
Implementierungen im Produktionscode und im Einheitentestcode verwendet werden können. In die Produktionsumgebung wird eine reale Implementierung eingefügt (z. B. eine, die die tatsächliche Systemzeit liest). Im Komponententest können wir jedoch eine „gefälschte“ Implementierung einfügen, die einen konstanten oder vordefinierten DateTime
Wert zurückgibt, der zum Testen des jeweiligen Szenarios geeignet ist.
Eine gefälschte Implementierung von IDateTimeProvider
könnte so aussehen:
public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }
Mit Hilfe dieser Klasse ist es möglich, den SmartHomeController
von nicht deterministischen Faktoren zu isolieren und einen zustandsbasierten Unit-Test durchzuführen. Lassen Sie uns überprüfen, ob, wenn eine Bewegung erkannt wurde, die Zeit dieser Bewegung in der LastMotionTime
Eigenschaft aufgezeichnet wird:
[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }
Toll! Ein solcher Test war vor dem Refactoring nicht möglich. Nachdem wir nicht-deterministische Faktoren eliminiert und das zustandsbasierte Szenario verifiziert haben, glauben Sie, dass SmartHomeController
vollständig testbar ist?
Vergiftung der Codebasis mit Nebeneffekten
Trotz der Tatsache, dass wir die Probleme gelöst haben, die durch die nicht deterministische versteckte Eingabe verursacht wurden, und wir in der Lage waren, bestimmte Funktionen zu testen, ist der Code (oder zumindest ein Teil davon) immer noch nicht testbar!
Sehen wir uns den folgenden Teil der ActuateLights(bool motionDetected)
-Methode an, der für das Ein- und Ausschalten des Lichts verantwortlich ist:
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }
Wie wir sehen können, delegiert SmartHomeController
die Verantwortung für das Ein- und Ausschalten des Lichts an ein BackyardLightSwitcher
-Objekt, das ein Singleton-Muster implementiert. Was ist falsch an diesem Design?
Um die ActuateLights(bool motionDetected)
-Methode vollständig zu testen, sollten wir zusätzlich zu den zustandsbasierten Tests interaktionsbasierte Tests durchführen. Das heißt, wir sollten sicherstellen, dass Methoden zum Ein- und Ausschalten des Lichts nur dann aufgerufen werden, wenn die entsprechenden Bedingungen erfüllt sind. Leider erlaubt uns das derzeitige Design nicht, dies zu tun: Die TurnOn()
und TurnOff()
Methoden von BackyardLightSwitcher
lösen einige Zustandsänderungen im System aus oder erzeugen mit anderen Worten Nebeneffekte . Die einzige Möglichkeit, zu überprüfen, ob diese Methoden aufgerufen wurden, besteht darin, zu überprüfen, ob die entsprechenden Nebenwirkungen tatsächlich aufgetreten sind oder nicht, was schmerzhaft sein kann.
Nehmen wir an, dass der Bewegungssensor, die Gartenlaterne und der Smart-Home-Mikrocontroller mit einem Internet der Dinge-Netzwerk verbunden sind und über ein drahtloses Protokoll kommunizieren. In diesem Fall kann ein Komponententest versuchen, diesen Netzwerkverkehr zu empfangen und zu analysieren. Oder wenn die Hardware-Komponenten mit einem Draht verbunden sind, kann der Unit-Test prüfen, ob die Spannung am entsprechenden Stromkreis anliegt. Oder er kann doch mit einem zusätzlichen Lichtsensor prüfen, ob das Licht tatsächlich ein- oder ausgeschaltet ist.
Wie wir sehen können, können Komponententests mit Nebeneffekten genauso schwierig sein wie Komponententests, die nicht deterministisch sind, und möglicherweise sogar unmöglich sein. Jeder Versuch wird zu Problemen führen, die denen ähneln, die wir bereits gesehen haben. Der resultierende Test wird schwer zu implementieren, unzuverlässig, potenziell langsam und nicht wirklich einheitlich sein. Und nach all dem wird uns das Blinken des Lichts jedes Mal, wenn wir die Testsuite ausführen, irgendwann verrückt machen!
Auch hier werden all diese Testbarkeitsprobleme durch die schlechte API verursacht, nicht durch die Fähigkeit des Entwicklers, Komponententests zu schreiben. Unabhängig davon, wie genau die Lichtsteuerung implementiert wird, leidet die SmartHomeController
-API unter diesen bereits bekannten Problemen:
Sie ist eng an die konkrete Umsetzung gekoppelt. Die API basiert auf der hartcodierten, konkreten Instanz von
BackyardLightSwitcher
. Es ist nicht möglich, dieActuateLights(bool motionDetected)
-Methode wiederzuverwenden, um ein anderes Licht als das im Hinterhof zu schalten.Es verstößt gegen das Single-Responsibility-Prinzip. Die API hat zwei Gründe für eine Änderung: Erstens Änderungen an der internen Logik (z. B. die Entscheidung, das Licht nur nachts einzuschalten, aber nicht abends) und zweitens, wenn der Lichtschaltmechanismus durch einen anderen ersetzt wird.
Es lügt über seine Abhängigkeiten. Entwickler können nicht wissen, dass
SmartHomeController
von der fest codiertenBackyardLightSwitcher
-Komponente abhängt, außer in den Quellcode einzudringen.Es ist schwer zu verstehen und zu pflegen. Was ist, wenn sich das Licht nicht einschaltet, wenn die Bedingungen stimmen? Wir könnten viel Zeit damit verbringen, den
SmartHomeController
vergeblich zu reparieren, nur um festzustellen, dass das Problem durch einen Fehler imBackyardLightSwitcher
verursacht wurde (oder, noch lustiger, eine durchgebrannte Glühbirne!).
Die Lösung sowohl der Testbarkeits- als auch der API-Probleme geringer Qualität besteht wenig überraschend darin, eng gekoppelte Komponenten voneinander zu trennen. Wie beim vorherigen Beispiel würde der Einsatz von Dependency Injection diese Probleme lösen; Fügen Sie dem SmartHomeController
einfach eine ILightSwitcher
-Abhängigkeit hinzu, delegieren Sie ihm die Verantwortung für das Umlegen des Lichtschalters und übergeben Sie eine gefälschte ILightSwitcher
-Implementierung nur zum Testen, die aufzeichnet, ob die entsprechenden Methoden unter den richtigen Bedingungen aufgerufen wurden. Anstatt jedoch erneut Dependency Injection zu verwenden, sehen wir uns einen interessanten alternativen Ansatz zur Entkopplung der Verantwortlichkeiten an.
Beheben der API: Funktionen höherer Ordnung
Dieser Ansatz ist eine Option in jeder objektorientierten Sprache, die erstklassige Funktionen unterstützt. Lassen Sie uns die Funktionsmerkmale von C# nutzen und die ActuateLights(bool motionDetected)
-Methode dazu bringen, zwei weitere Argumente zu akzeptieren: ein Paar Action
, die auf Methoden zeigen, die aufgerufen werden sollten, um das Licht ein- und auszuschalten. Diese Lösung wandelt die Methode in eine Funktion höherer Ordnung um :
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }
Dies ist eine funktionalere Lösung als der klassische objektorientierte Dependency-Injection-Ansatz, den wir zuvor gesehen haben. Allerdings können wir damit dasselbe Ergebnis mit weniger Code und mehr Ausdruckskraft erzielen als mit Dependency Injection. Es ist nicht mehr notwendig, eine schnittstellenkonforme Klasse zu implementieren, um SmartHomeController
mit der erforderlichen Funktionalität zu versorgen; Stattdessen können wir einfach eine Funktionsdefinition übergeben. Funktionen höherer Ordnung können als eine andere Möglichkeit zur Implementierung der Inversion of Control betrachtet werden.
Um nun einen interaktionsbasierten Komponententest der resultierenden Methode durchzuführen, können wir einfach verifizierbare gefälschte Aktionen daran übergeben:
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }
Schließlich haben wir die SmartHomeController
-API vollständig testbar gemacht und können sowohl zustandsbasierte als auch interaktionsbasierte Einheitentests dafür durchführen. Beachten Sie auch hier, dass zusätzlich zur verbesserten Testbarkeit das Einführen einer Naht zwischen dem Entscheidungsfindungs- und Aktionscode dazu beigetragen hat, das Problem der engen Kopplung zu lösen, und zu einer saubereren, wiederverwendbaren API geführt hat.
Um eine vollständige Unit-Test-Abdeckung zu erreichen, können wir jetzt einfach eine Reihe ähnlich aussehender Tests implementieren, um alle möglichen Fälle zu validieren – keine große Sache, da Unit-Tests jetzt recht einfach zu implementieren sind.
Unreinheit und Testbarkeit
Unkontrollierter Nichtdeterminismus und Nebenwirkungen sind in ihren destruktiven Auswirkungen auf die Codebasis ähnlich. Wenn sie sorglos verwendet werden, führen sie zu irreführendem, schwer verständlichem und zu wartendem, eng gekoppeltem, nicht wiederverwendbarem und nicht testbarem Code.
Andererseits sind Methoden, die sowohl deterministisch als auch nebenwirkungsfrei sind, viel einfacher zu testen, zu begründen und wiederzuverwenden, um größere Programme zu erstellen. In Bezug auf die funktionale Programmierung werden solche Methoden als reine Funktionen bezeichnet . Wir werden selten ein Problem beim Unit-Testen einer reinen Funktion haben; Alles, was wir tun müssen, ist, einige Argumente zu übergeben und das Ergebnis auf Korrektheit zu überprüfen. Was Code wirklich untestbar macht, sind fest codierte, unreine Faktoren, die nicht ersetzt, außer Kraft gesetzt oder auf andere Weise abstrahiert werden können.
Unreinheit ist giftig: Wenn die Methode Foo()
von der nicht deterministischen oder nebenwirkungsträchtigen Methode Bar()
) abhängt, dann wird auch Foo()
nicht deterministisch oder nebenwirkungsreich. Letztendlich könnten wir die gesamte Codebasis vergiften. Multiplizieren Sie all diese Probleme mit der Größe einer komplexen realen Anwendung, und wir werden uns mit einer schwer zu wartenden Codebasis voller Gerüche, Anti-Patterns, geheimer Abhängigkeiten und allen möglichen hässlichen und unangenehmen Dingen konfrontiert sehen.
Eine Verunreinigung ist jedoch unvermeidlich; Jede reale Anwendung muss irgendwann den Status lesen und bearbeiten, indem sie mit der Umgebung, Datenbanken, Konfigurationsdateien, Webdiensten oder anderen externen Systemen interagiert. Anstatt darauf abzuzielen, Verunreinigungen insgesamt zu eliminieren, ist es eine gute Idee, diese Faktoren einzuschränken, zu vermeiden, dass sie Ihre Codebasis vergiften, und hartcodierte Abhängigkeiten so weit wie möglich zu brechen, um Dinge unabhängig analysieren und testen zu können.
Häufige Warnzeichen von schwer zu testendem Code
Lassen Sie uns abschließend einige häufige Warnsignale betrachten, die darauf hinweisen, dass unser Code möglicherweise schwierig zu testen ist.
Statische Eigenschaften und Felder
Statische Eigenschaften und Felder oder, einfach ausgedrückt, der globale Status können das Verständnis und die Testbarkeit von Code erschweren, indem sie die Informationen verbergen, die eine Methode benötigt, um ihre Arbeit zu erledigen, indem sie Nichtdeterminismus einführen oder die umfassende Nutzung von Nebeneffekten fördern. Funktionen, die veränderliche globale Zustände lesen oder modifizieren, sind von Natur aus unrein.
Zum Beispiel ist es schwierig, über den folgenden Code nachzudenken, der von einer global zugänglichen Eigenschaft abhängt:
if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }
Was ist, wenn die HeatWater()
Methode nicht aufgerufen wird, wenn wir sicher sind, dass es hätte sein sollen? Da jeder Teil der Anwendung den CostSavingEnabled
Wert geändert haben könnte, müssen wir alle Stellen finden und analysieren, an denen dieser Wert geändert wird, um herauszufinden, was falsch ist. Wie wir bereits gesehen haben, ist es auch nicht möglich, einige statische Eigenschaften zu Testzwecken festzulegen (z. B. DateTime.Now
oder Environment.MachineName
; sie sind schreibgeschützt, aber immer noch nicht deterministisch).
Andererseits ist ein unveränderlicher und deterministischer globaler Zustand völlig in Ordnung. Tatsächlich gibt es dafür einen bekannteren Namen – eine Konstante. Konstante Werte wie Math.PI
führen keinen Nicht-Determinismus ein und erlauben keine Seiteneffekte, da ihre Werte nicht geändert werden können:
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!
Singles
Im Wesentlichen ist das Singleton-Muster nur eine andere Form des globalen Zustands. Singletons fördern obskure APIs, die über echte Abhängigkeiten lügen und eine unnötig enge Kopplung zwischen Komponenten einführen. Sie verstoßen auch gegen das Single-Responsibility-Prinzip, da sie zusätzlich zu ihren Hauptaufgaben ihre eigene Initialisierung und ihren Lebenszyklus kontrollieren.
Singletons können Unit-Tests leicht auftragsabhängig machen, da sie den Status für die gesamte Lebensdauer der gesamten Anwendung oder Unit-Test-Suite mit sich herumtragen. Sehen Sie sich das folgende Beispiel an:
User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }
In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache
after each unit test run.
Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.
The new
Operator
Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.
For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:
using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }
However, sometimes new
is absolutely harmless: for example, it is OK to create simple entity objects:
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack
methods were called or not — we just check if the end result is correct:
string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }
Static Methods
Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.
For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:
void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }
However, pure static functions are OK: any combination of them will still be a pure function. Zum Beispiel:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }
Benefits of Unit Testing
Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.
As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.