Skip to main content

Unit-Tests und Mocking in Python neu betrachtet

Artikel von
blog hero python code purple

7. Juli 2018

0 Min. Lesezeit

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:

import http_client
 class CloudCreator :

def __init__(self) :

self.network_client = http_client.HTTPClient() 

Wir können CloudCreator wie folgt testen:

import unittest
import http_client from unittest.mock 
import MagicMock, patch 

class TestCloudCreator (unittest.TestCase) :  
@patch('http_client.HTTPClient')   

	def setUp (self, mock_http_client_call) :   
	self.mock_http_client = MagicMock(autospec=http_client.HTTPClient)

	# Recall that patch patches the _initialization_ call of classes 
	mock_http_client_call.return_value = self.mock_http_client   
	self.cloud_creator = CloudCreator()

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 patch mehrere voneinander unabhängige Unit-Tests aktualisiert werden. Außerdem ist nicht immer klar, welche Unit-Tests geändert werden müssen, da patch fest codierte Zeichenfolgen statt stärker „typisierter“ Referenzen verwendet (die von Lintern oder IDEs erkannt werden können).

  • Die Verwendung von patch ist 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 patch angewiesen 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):

class CloudCreator :   
def __init__(self, network_client: NetworkClient) : 
self.network_client = network_client 

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:

import unittest from unittest.mock 
import MagicMock 
class TestCloudCreator (unittest.TestCase) :   

def setUp (self) :   
self.mock_network_client = MagicMock(autospec=NetworkClient)  
self.cloud_creator = CloudCreator(self.mock_network_client)

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:

class CloudCreator :     
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None:
 AccountsClient, 
 log_writer=None: LogWriter, 
 health_check=None: HealthCheckClient, 
 metrics=None: MetricsWriter, 
 database_client=None: DatabaseClient) :   

 self.network_client = network_client 
 self.authz_client = authz_client 
 self.accts_client = accts_client 
 self.log_writer = log_writer
 self.health_check = health_check 
 self.metrics = metrics 
 self.database_client = database_client

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:

class CloudCreatorServices :   
def __init__() : 

 self.network_client = HTTPClient()   
 self.database_client = SQLClient()     
 self.authz_client = AuthzClient(self.database_client)   
 self.accts_client = AcctsClient(self.database_client)   
 self.log_writer = LogWriter()   
 self.health_check = HealthCheck()   
 self.metrics = Metrics() 

def main() :  
 services = CloudCreatorServices()  
 cc = CloudCreator(services.network_client, services.authz_client, services.accts_client, services.log_writer, services.health_check, services.metrics, services.database_client)

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:

class CloudCreator : 

def __init__(self, services: CloudCreatorServices) :   
	self.services = services 

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:

class CloudCreatorServices :   
def __init__(self, network_client: NetworkClient, authz_client: AuthzClient, accts_client: AccountsClient, log_writer: LogWriter, health_check: HealthCheckClient, metrics: MetricsWriter, database_client: DatabaseClient) :

	self.network_client = network_client   
	self.authz_client = authz_client   
	self.accts_client = accts_client   
	self.log_writer = log_writer   
	self.health_check = health_check   
	self.metrics = metrics   
	self.database_client = database_client

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:

class CloudCreatorServices :   
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self.database_client = database_client or self._get_database_client()   
	self.network_client = network_client or self._get_network_client()   
	self.authz_client = authz_client or self._get_authz_client(self.database_client)   
	self.accts_client = accts_client or self._get_accts_client(self.database_client)   
	self.log_writer = log_writer or self._get_log_writer()   
	self.health_check = health_check or self._get_health_check()   
	self.metrics = metrics or self._get_metrics() 

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:

class CloudCreatorServices :  
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self._database_client = database_client   
	self._network_client = network_client   
	self._authz_client = authz_client  
	self._accts_client = accts_client   
	self._log_writer = log_writer   
	self._health_check = health_check  
	self._metrics = metrics  

def get_database_client (self) DatabaseClient :   

	if not self._database_client:   
	self._database_client = DatabaseClient()   
	return self._database_client  

	def get_authz_client (self) AuthzClient :   

	if not self._authz_client:   
	self.authz_client = AuthzClient(self.get_database_client())   
	return self.authz_client 
	...

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:

class CloudCreatorServices :   
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self._database_client = database_client   
	self._network_client = network_client   
	self._authz_client = authz_client   
	self._accts_client = accts_client   
	self._log_writer = log_writer   
	self._health_check = health_check   
	self._metrics = metrics @property   

def database_client (self)  DatabaseClient :   

	if not self._database_client:    
	self._database_client = DatabaseClient()   
	return self._database_client  

def authz_client (self)  AuthzClient :   

	if not self._authz_client:    
	self.authz_client = AuthzClient(self.database_client)   
	return self.authz_client  
	...

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:

class CloudCreatorServices :  
... 
@property   
def database_client (self)  DatabaseClient :   

if not self._database_client:    
self._database_client = DatabaseClient()   
return self._database_client @database_client.setter   

def _set_database_client (self, database_client: DatabaseClient) :   
self.database_client = database_client

Der Setter wird automatisch aufgerufen, wenn wir database_client einen Wert zuweisen:

services = CloudCreatorServices() 
# calls _set_database_client services.database_client = DatabaseClient()

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:

import unittest from unittest.mock 
import MagicMock class TestCloudCreator (unittest.TestCase) :   

def test_network_write_method (self) :   
	self.mock_network_client = MagicMock(autospec=NetworkClient)   
	self.cloud_creator = CloudCreator(network_client=self.mock_network_client)   
	...

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.

Gepostet in: