Unit-Tests und Mocking in Python neu betrachtet
7. Juli 2018
0 Min. LesezeitAnmerkung der Redaktion
Dieser Blogbeitrag erschien ursprünglich auf fugue.co. Fugue wurde 2022 Teil von Snyk und ist ein wichtiger Bestandteil von Snyk IaC.
In meinem vorherigen Blogbeitrag Python Mocking 101: Fake It Before You Make It ging es um die grundlegenden Mechanismen von Mocking und Unit-Tests in Python. Dieser Beitrag behandelt einige übergeordnete Prinzipien der Softwareentwicklung, die sich in meinen Erfahrungen mit Python-Tests im letzten Jahr und den vergangenen sechs Monaten gezeigt haben. Insbesondere möchte ich die Idee des Patchens von Mock-Objekten in Unit-Tests erneut aufgreifen.
Externe Clients patchen
Clients bezeichnet in diesem Beitrag beliebige Objekte, die Seiteneffekte verursachen, etwa Festplatten- oder Netzwerk-I/O. Betrachten wir eine Klasse, CloudCreator, die Nachrichten über HTTP empfängt, durch das Erstellen von Cloud-Infrastruktur bestimmte Seiteneffekte auslöst und als Antwort Nachrichten über HTTP sendet:
Wir können CloudCreator wie folgt testen:
Mit patch können wir unsere Klasse CloudCreator testen, ohne Netzwerk-Seiteneffekte auszulösen. Dieser Ansatz hat jedoch einige Schwächen. Wenn CloudCreator viele externe Clients verwendet, müssen wir zahlreiche patch-Aufrufe aneinanderreihen. Außerdem sind CloudCreator und seine Unit-Tests stark von HTTPClient abhängig, was den Wechsel des Netzwerk-Clients erschwert.
Der Fugue-Engineer Josh Einhorn weist auf weitere Nachteile von patch hin:
Bei der Verwendung gibt es irgendwo in der Klasse implizite Abhängigkeiten – andere Entwickler würden davon nichts erfahren. Konstruktorargumente machen Abhängigkeiten explizit.
Wenn die zugrunde liegenden Implementierungen umgestaltet werden, müssen bei Verwendung von
patchmehrere voneinander unabhängige Unit-Tests aktualisiert werden. Außerdem ist nicht immer klar, welche Unit-Tests geändert werden müssen, dapatchfest codierte Zeichenfolgen statt stärker „typisierter“ Referenzen verwendet (die von Lintern oder IDEs erkannt werden können).Die Verwendung von
patchist ein Code-Smell, denn sie bedeutet, dass die getestete Klasse an eine oder mehrere andere konkrete Klassen gekoppelt ist.Code, der für Unit-Tests auf
patchangewiesen ist, lässt sich nicht auf andere Programmiersprachen übertragen. Statisch typisierte Sprachen mit Compilern erlauben kein Monkey-Patching (jedenfalls nicht ohne erheblichen Aufwand). Damit sich eine solche Klasse in einer anderen Sprache angemessen mit Unit-Tests testen lässt, wäre eine strukturelle Umgestaltung erforderlich.
Generell gilt: Wenn zum Testen einer Klasse viele externe Clients mit patch gepatcht werden müssen, ist das ein Zeichen dafür, dass eine Umgestaltung nötig ist. Erfahrene Softwareentwickler erkennen, dass dieses Beispiel eine ideale Gelegenheit für Dependency Inversion bietet.
Dependency Inversion und Injection
Dependency Inversion und insbesondere Dependency Injection sind in der Softwareentwicklung wohlbekannte Konzepte. Für alle, die damit noch nicht vertraut sind: Bei Dependency Injection erhält eine Klasse oder Funktion die benötigten externen Clients, statt sie selbst zu erstellen. So kann der Code in unterschiedlichen Kontexten ausgeführt werden – je nachdem, welche Clients ihm übergeben werden.
In unserem Beispiel ist die zentrale Funktion von CloudCreator, Cloud-Infrastruktur zu erstellen, nicht von einer bestimmten Methode zum Senden und Empfangen von Nachrichten abhängig. Daher ist es sinnvoll, die Klasse so zu schreiben, dass die Netzwerk-I/O von einem zur Laufzeit injizierten Client verarbeitet wird und nicht fest codiert ist (dieser Code verwendet die Python-Syntax für Type Hints):
Dank dieser Kapselung kann die Klasse mit einem HTTP-Client, einem TCP/IP-Client, einem ZMQ-Client oder einem SQS/SNS-Client verwendet werden – vorausgesetzt, die Netzwerk-I/O entspricht einer vordefinierten Schnittstelle, die in NetworkClient festgelegt ist, etwa NetworkClient.recv() und NetworkClient.send(data). Das vereinfacht den Prozess erheblich. Aufmerksamen Lesern wird auffallen, dass die Spezifikation der Client-Schnittstelle dabei von zentraler Bedeutung ist – aber das ist ein Thema für einen anderen Blogbeitrag.
Ein wesentlicher Vorteil von Dependency Injection ist, dass Entwickler beim Testen mit Unit-Tests ganz einfach Mock-Objekte übergeben können. Unsere Tests können wir jetzt so aufsetzen:
Für die Unit-Tests erstellen wir einen Mock-Netzwerk-Client. Dazu verwenden wir das Argument autospec von MagicMock, um ein Mock-Objekt zu erzeugen, das der Schnittstelle NetworkClient entspricht.
In diesem einfachen Beispiel konnten wir mit patch ein unflexibles Design kaschieren, indem wir einen noch unflexibleren Unit-Test erstellt haben. Wie bereits erwähnt: Wenn Sie mehr als nur einige wenige Aufrufe patchen, ist das ein Zeichen dafür, dass eine Umgestaltung nötig ist. Beachten Sie, dass patch weiterhin nützlich ist, beispielsweise um Aufrufe von time.time() oder anderen Bibliotheksfunktionen ohne Seiteneffekte zu patchen.
Mehr Dependency Injection
Dependency Injection ist nützlich. Doch was tun, wenn unsere Klasse viele Clients benötigt? Wir können weitere Parameter hinzufügen und alle einzeln injizieren:
Durch das Festlegen von Standardwerten können Clients gezielt initialisiert werden, was Unit-Tests erleichtert (mehr dazu später). Diese Form ist jedoch weiterhin umständlich, insbesondere bei Integrationstests. Was passiert, wenn wir unseren Nachrichten-Handler ändern und anschließend testen möchten, ob er korrekt mit dem Server kommuniziert? Die Initialisierung von CloudCreator erfordert viel mühsame Arbeit, um Client-Objekte zu erstellen und zu initialisieren. Eine der Stärken von Python ist sein interaktiver Interpreter, der einen iterativen Entwicklungsprozess ermöglicht. Wenn Entwickler den REPL einfach nutzen können, erleichtert das ihren Arbeitsalltag. Müssen Entwickler erst ein Dutzend Client-Objekte erstellen und initialisieren, bevor sie eine kleine Änderung an der Kernklasse testen können, sorgt das für Frustration.
Eine Lösung besteht darin, die Erstellung der Clients in einem separaten Objekt zu kapseln. Dabei können wir sogar Informationen zur Reihenfolge der Abläufe und zu den Abhängigkeiten im Konstruktor festhalten:
Beachten Sie, dass CloudCreator weiterhin mit expliziten Verweisen auf die benötigten Services initialisiert wird. So können künftige Entwickler leicht nachvollziehen, welche Services CloudCreator benötigt. Denkbar wäre auch ein Design, bei dem der Konstruktor von CloudCreator nur ein Objekt vom Typ CloudCreatorServices erwartet:
Dadurch wird CloudCreator jedoch an eine bestimmte Implementierung von CloudCreatorServices gebunden, die genau die Services enthält, die CloudCreator benötigt. Wird CloudCreatorServices so verallgemeinert, dass damit Services für mehrere Klassen erstellt werden können, muss der Aufrufer davon ausgehen, dass jede Klasse, die die verallgemeinerte Klasse Service verwendet, jeden einzelnen Service benötigt.
Leider geht bei dieser naiven Implementierung die Flexibilität der direkten Dependency Injection verloren. Ein Schritt vorwärts, ein Schritt zurück. Das lässt sich leicht beheben:
Bis hierhin haben wir lediglich die Komplexität verlagert. Entwickler müssen weiterhin alle Clients initialisieren. Wir haben ihnen noch keine Hilfsmittel an die Hand gegeben, um ihnen die Arbeit zu erleichtern. Das können wir ändern, indem wir komplexe Initialisierungsprozesse in CloudCreatorServices integrieren:
Jetzt haben wir die Erstellung der Clients in diesen Initialisierungsmethoden verborgen. Das scheint eine gute Lösung zu sein, hat bei näherer Betrachtung aber einen Nachteil: Bei der Initialisierung von CloudCreatorServices erstellen wir alle Clients, selbst wenn wir sie gar nicht verwenden werden. Was tun wir, wenn einer unserer Client-Services Probleme macht und eine Zeitüberschreitung verursacht, wir aber trotzdem andere Funktionen testen möchten? Können wir noch mehr Flexibilität erreichen?
Mit Getter-Methoden können wir die Reihenfolge der Initialisierung ändern:
Diese Lösung ermöglicht Lazy Loading: Clients werden nur bei Bedarf initialisiert, und wir können sie weiterhin nach Bedarf austauschen. Getter-Methoden sind jedoch nicht besonders Python-typisch. Gibt es ein Sprach-Feature, mit dem sich eine Python-typischere Lösung finden lässt?
@property
Das gesuchte Feature ist @property:
Das ähnelt unserer vorherigen Lösung mit Getter-Methoden, allerdings haben wir get_ weggelassen und den Decorator @property hinzugefügt. Mit @property wird eine Getter-Methode in eine Property umgewandelt. Auf eine Property kann direkt zugegriffen werden, etwa mit CloudCreatorServices.database_client, also ohne Klammern. Außerdem können wir mit @property später einen Setter hinzufügen, indem wir die Setter-Funktion für eine Property beispielsweise mit @.setter dekorieren:
Der Setter wird automatisch aufgerufen, wenn wir database_client einen Wert zuweisen:
Mit @property bleibt der Python-Standard erhalten, direkt auf Instanzattribute zuzugreifen. Gleichzeitig können wir den Attributzugriff flexibel mit Gettern und Settern umschließen.
Mocking mit Dependency Injection
Einer der Vorteile von Dependency Inversion ist, dass sie Unit-Tests deutlich vereinfacht. Denken Sie daran, dass die Initialisierungsargumente von CloudCreator Standardwerte haben. So können wir für bestimmte Tests gezielt Mock-Objekte für Client-Services verwenden:
Da die anderen Client-Services nicht initialisiert werden, lässt sich leicht erkennen, ob der Netzwerk-Codepfad auf Objekte außerhalb seines Bereichs zugreift. Das ist normalerweise ein Zeichen dafür, dass etwas nicht stimmt. Umfassende Unit-Tests würden natürlich Mocks für alle Services erfordern, die sich aber problemlos hinzufügen lassen.
Durch Dependency Inversion konnten wir in unseren Unit-Tests auf das Patchen mit patch verzichten und Entwicklern zugleich ein leistungsstarkes, zeitsparendes Werkzeug für Integrationstests an die Hand geben.
