Skip to main content

Python-Mocking 101: So tun, als ob – bevor Sie es wirklich tun

Artikel von
blog hero python code purple

10. Februar 2018

0 Min. Lesezeit

FugueWillkommen zu einem Leitfaden für die Grundlagen des Mockings in Python. Entstanden ist er aus meinem Bedarf, Code zu testen, der zahlreiche Netzwerkdienste nutzte, und aus meinen Erfahrungen mit GoMock, die mir gezeigt haben, wie leistungsfähig Mocking sein kann, wenn es richtig eingesetzt wird (danke, Tyler). Zunächst möchte ich über die Philosophie des Mockings sprechen, denn gutes Mocking erfordert eine andere Denkweise als gute Entwicklung. Bei der Entwicklung geht es darum, Dinge zu erschaffen, beim Mocking darum, Dinge vorzutäuschen. Das mag offensichtlich klingen, doch das „Vortäuschen“ bei Mocking-Tests geht tief und verändert das Verständnis von Tests grundlegend. Anschließend sehen wir uns die Mocking-Tools von Python an und schließen mit einem vollständigen Beispiel. Erfahren Sie mit unserem Cheat Sheet mehr über das Testen von Code im Hinblick auf Python-Sicherheit.

Mocking kann schwer zu verstehen sein. Wenn ich selbst geschriebenen Code teste, möchte ich sehen, ob er von Anfang bis Ende das tut, was er soll. Normalerweise denke ich zuerst an einen funktionalen Integrationstest, bei dem ich realistische Eingaben mache und realistische Ausgaben erhalte. Ich greife auf alle realen Systeme zu, die mein Code verwendet, um sicherzustellen, dass die Interaktionen zwischen diesen Systemen korrekt funktionieren – mit echten Objekten und echten API-Aufrufen. Solche Tests sind zwar unerlässlich, um zu prüfen, ob komplexe Systeme reibungslos zusammenarbeiten, aber für Unit-Tests sind sie nicht geeignet.

Bei Unit-Tests wird die äußerste Ebene des Codes getestet. Integrationstests sind notwendig, aber unsere automatisierten Unit-Tests sollten nicht so tief in die Interaktion zwischen Systemen eindringen. Das bedeutet, dass API-Aufrufe in der getesteten Funktion gemockt werden können und sollten. Ersetzen Sie alle nicht trivialen API-Aufrufe oder Objekterstellungen durch Mock-Aufrufe oder Mock-Objekte. So vermeiden Sie unnötigen Ressourcenverbrauch, vereinfachen die Einrichtung Ihrer Tests und verkürzen deren Laufzeit. Stellen Sie sich vor, Sie testen eine Funktion, die auf eine externe HTTP-API zugreift. Statt sicherzustellen, dass ein Testserver bereitsteht und die richtigen Antworten sendet, können Sie die HTTP-Bibliothek mocken und alle HTTP-Aufrufe durch Mock-Aufrufe ersetzen. Das verringert die Komplexität und die Abhängigkeiten des Tests und gibt Ihnen genaue Kontrolle darüber, was die HTTP-Bibliothek zurückgibt – was sonst möglicherweise schwierig wäre.

Was verstehen wir unter Mocking?

Der Begriff Mocking wird häufig verwendet. In diesem Dokument gilt folgende Definition:

„Das Ersetzen eines oder mehrerer Funktionsaufrufe oder Objekte durch Mock-Aufrufe oder Mock-Objekte“

Ein Mock-Funktionsaufruf gibt sofort einen vordefinierten Wert zurück, ohne Arbeit auszuführen. Die Attribute und Methoden eines Mock-Objekts werden ebenfalls vollständig im Test definiert, ohne das echte Objekt zu erstellen oder Arbeit auszuführen. Dass die Person, die den Test schreibt, die Rückgabewerte jedes Funktionsaufrufs festlegen kann, verschafft ihr beim Testen enorme Möglichkeiten. Zugleich muss sie aber einige Grundlagen schaffen, damit alles korrekt eingerichtet ist.

In Python erfolgt Mocking über das Modul unittest.mock. Das Modul enthält eine Reihe nützlicher Klassen und Funktionen. Die wichtigsten davon sind die Funktion patch (als Decorator und Context Manager) und die Klasse MagicMock. Mocking in Python erfolgt größtenteils mit diesen beiden leistungsstarken Komponenten.

Was verstehen wir NICHT unter Mocking?

Entwicklerinnen und Entwickler verwenden viele „Mock“-Objekte oder -Module, also voll funktionsfähige lokale Ersatzlösungen für Netzwerkdienste und APIs. Die Bibliothek moto ist beispielsweise eine Mock-Bibliothek für boto, die alle boto-API-Aufrufe abfängt und lokal verarbeitet. Zwar können Entwicklerinnen und Entwickler mit solchen Mocks externe APIs lokal testen, dafür müssen sie aber weiterhin echte Objekte erstellen. Diese Art von Mocking ist nicht Gegenstand dieses Dokuments. Hier geht es speziell um die Verwendung von MagicMock-Objekten, um den Kontrollfluss der getesteten Funktion vollständig zu steuern. So lassen sich Fehler und die Ausnahmebehandlung einfach testen.

Wie mocken wir in Python?

In Python wird Mocking mit patch umgesetzt, um einen API-Aufruf oder eine Objekterstellung abzufangen. Fängt patch einen Aufruf ab, gibt die Funktion standardmäßig ein MagicMock-Objekt zurück. Indem Sie Eigenschaften des MagicMock-Objekts festlegen, können Sie den API-Aufruf so mocken, dass er einen beliebigen Wert zurückgibt oder eine Exception auslöst.

Der allgemeine Ablauf sieht folgendermaßen aus:

  1. Schreiben Sie den Test so, als würden Sie echte externe APIs verwenden.

  2. Ermitteln Sie in der getesteten Funktion, welche API-Aufrufe gemockt werden müssen. Das sollten nur wenige sein.

  3. Fangen Sie die API-Aufrufe in der Testfunktion mit patch ab.

  4. Richten Sie die Antworten des MagicMock -Objekts ein.

  5. Führen Sie Ihren Test aus.

Wenn Ihr Test erfolgreich ist, sind Sie fertig. Andernfalls enthält möglicherweise die getestete Funktion einen Fehler, oder Sie haben die Antwort Ihres MagicMock falsch eingerichtet. Im Folgenden sehen wir uns die Tools zum Erstellen und Konfigurieren von Mocks genauer an.

patch

import unittest 
from unittest.mock import patch

patch kann als Decorator für die Testfunktion verwendet werden. Als Argument wird eine Zeichenfolge mit dem Namen der Funktion übergeben, die abgefangen werden soll. Damit patch die abzufangende Funktion findet, muss sie mit ihrem vollständig qualifizierten Namen angegeben werden. Dieser ist möglicherweise nicht der, den Sie erwarten. Wird eine Klasse mit einer from module import ClassA-Anweisung importiert, wird ClassA Teil des Namensraums des Moduls, in das sie importiert wird.

Wenn beispielsweise eine Klasse im Modul my_module.py folgendermaßen importiert wird:

[in my_module.py] 
from module import ClassA

muss sie mit @patch(my_module.ClassA) statt mit @patch(module.ClassA) abgefangen werden. Das liegt an der Semantik der Anweisung from ... import ..., die Klassen und Funktionen in den aktuellen Namensraum importiert.

Typischerweise wird patch verwendet, um einen Aufruf einer externen API oder eine andere zeit- oder ressourcenintensive Funktion bzw. Objekterstellung abzufangen. Pro Test sollten Sie nur wenige aufrufbare Objekte abfangen. Wenn Sie mehr als eine Handvoll Aufrufe mit patch abfangen möchten, sollten Sie Ihren Test oder die getestete Funktion refaktorieren.

Wenn Sie den patch-Decorator verwenden, wird der Funktion, die Sie dekorieren (also Ihrer Testfunktion), automatisch ein positionsbasiertes Argument übergeben. Werden mehrere Funktionen abgefangen, wird der Decorator, der der dekorierten Funktion am nächsten ist, zuerst aufgerufen und erzeugt somit das erste positionsbasierte Argument.

@patch('module.ClassB')
@patch('module.functionA')

def test_some_func(self, mock_A, mock_B): 
...

Standardmäßig sind diese Argumente Instanzen von MagicMock, dem standardmäßigen Mocking-Objekt von unittest.mock. Sie können das Verhalten der abgefangenen Funktion festlegen, indem Sie Attribute der zurückgegebenen MagicMock-Instanz setzen.

MagicMock

MagicMock-Objekte bieten eine einfache Mocking-Schnittstelle, mit der Sie den Rückgabewert oder ein anderes Verhalten des Funktions- oder Objekterstellungsaufrufs festlegen können, den Sie abgefangen haben. So können Sie das Verhalten des Aufrufs vollständig definieren und müssen keine echten Objekte erstellen, was aufwendig sein kann. Fangen wir beispielsweise einen Aufruf von requests.get ab, einem Aufruf der HTTP-Bibliothek, können wir eine Antwort definieren, die zurückgegeben wird, wenn die API in der getesteten Funktion aufgerufen wird. So müssen wir keinen Testserver bereitstellen, der die gewünschte Antwort zurückgibt.

Die beiden wichtigsten Attribute einer MagicMock-Instanz sind return_value und side_effect. Mit beiden können wir das Rückgabeverhalten des abgefangenen Aufrufs festlegen.

return_value

Mit dem Attribut return_value der an Ihre Testfunktion übergebenen MagicMock-Instanz legen Sie fest, was das abgefangene aufrufbare Objekt zurückgibt. Meist möchten Sie eine Mock-Version dessen zurückgeben, was das aufrufbare Objekt normalerweise zurückgeben würde. Das kann JSON, ein iterierbares Objekt, ein Wert, eine Instanz des echten Antwortobjekts, ein MagicMock, das sich als Antwortobjekt ausgibt, oder nahezu alles andere sein. Beim Abfangen von Objekten wird der Aufruf zur Objekterstellung abgefangen. Daher sollte return_value des MagicMock ein Mock-Objekt sein, zum Beispiel ein weiteres MagicMock.

Wenn der Code, den Sie testen, Python-typisch ist und Duck Typing statt expliziter Typisierung verwendet, kann ein MagicMock als Antwortobjekt praktisch sein. Statt eine echte Instanz einer Klasse aufwendig zu erstellen, können Sie im Konstruktor von MagicMock beliebige Schlüssel-Wert-Paare für Attribute definieren. Diese werden automatisch auf die Instanz angewendet.

[in test_my_module]
@patch('external_module.api_call')
def test_some_func(self, mock_api_call): 
mock_api_call.return_value = MagicMock(status_code=200,response=json.dumps({'key':'value'})) 
my_module.some_func()
[in my_module]import external_module
def some_func(): 
response = external_module.api_call()  
#normally returns a Response object, but now returns a MagicMock
#response == mock_api_call.return_value == MagicMock(status_code=200, response=json.dumps({'key':'value'}))

Beachten Sie: Das an test_some_func übergebene Argument, also mock_api_call, ist ein MagicMock, und wir setzen return_value auf ein weiteres MagicMock. Beim Mocking ist alles ein MagicMock.

Ein MagicMock mit Spezifikation

Die Flexibilität eines MagicMock ist zwar praktisch, wenn Klassen mit komplexen Anforderungen schnell gemockt werden sollen, kann aber auch ein Nachteil sein. Standardmäßig verhalten sich MagicMock-Objekte so, als hätten sie jedes beliebige Attribut – auch solche, die sie nicht haben sollten. Im obigen Beispiel geben wir ein MagicMock-Objekt statt eines Response-Objekts zurück. Angenommen, uns wäre beim Aufruf von patch ein Fehler unterlaufen und wir hätten eine Funktion abgefangen, die eigentlich ein Request-Objekt statt eines Response-Objekts zurückgeben sollte. Das zurückgegebene MagicMock würde trotzdem so tun, als hätte es alle Attribute des Request-Objekts, obwohl es ein Response-Objekt nachbilden sollte. Das kann zu verwirrenden Testfehlern und falschem Testverhalten führen.

Die Lösung besteht darin, beim Erstellen des MagicMock mit dem Schlüsselwortargument spec eine spec anzugeben: MagicMock(spec=Response). Dadurch entsteht ein MagicMock, auf dessen Attribute und Methoden nur dann zugegriffen werden kann, wenn sie in der Klasse vorhanden sind, an der sich die Spezifikation des MagicMock orientiert. Der Zugriff auf ein Attribut, das im ursprünglichen Objekt nicht vorhanden ist, löst wie beim echten Objekt einen AttributeError aus.

Ein einfaches Beispiel:

m = MagicMock()m.foo() 
#no error raised
# Response objects have a status_code attributem = MagicMock(spec=Response, status_code=200, response=json.dumps({‘key’:’value’}))m.foo() 
#raises AttributeErrorm.status_code #no error raised

side_effect

Manchmal möchten Sie testen, ob Ihre Funktion eine Exception korrekt behandelt oder ob mehrere Aufrufe der abgefangenen Funktion korrekt verarbeitet werden. Dafür können Sie side_effect verwenden. Wird side_effect auf eine Exception gesetzt, wird diese sofort ausgelöst, wenn die abgefangene Funktion aufgerufen wird.

Wird side_effect auf ein iterierbares Objekt gesetzt, wird bei jedem Aufruf der abgefangenen Funktion das nächste Element daraus zurückgegeben. Bei jedem anderen Wert gibt side_effect genau diesen Wert zurück.

[in test_my_module]
@patch('external_module.api_call')

def test_some_func(self, mock_api_call): 
mock_api_call.side_effect = SomeException() 
my_module.some_func()[in my_module]def some_func(): 
try:  
	external_module.api_call() 

except SomeException:  
	print(“SomeException caught!”) 
	# this code is executed 
	except SomeOtherException:  
	print(“SomeOtherException caught!”) 
	# not executed[in test_my_module]
@patch('external_module.api_call')

def test_some_func(self, mock_api_call): 
	mock_api_call.side_effect = [0, 1] 
	my_module.some_func()[in my_module]

def some_func(): 
	rv0 = external_module.api_call() 
	# rv0 == 0 
	rv1 = external_module.api_call() 
	# rv1 == 1

assert_called_with

Mit assert_called_with wird geprüft, ob die abgefangene Funktion mit den Argumenten aufgerufen wurde, die an assert_called_with übergeben wurden.

[inside some_func]someAPI.API_call(foo, bar='baz')[inside test_some_func]some_func()mock_api_call.assert_called_with(foo, bar='baz')

Ein vollständiges Beispiel

In diesem Beispiel teste ich eine retry-Funktion für Client.update. Das bedeutet, dass die API-Aufrufe in update zweimal ausgeführt werden – ein idealer Anwendungsfall für MagicMock.side_effect.

Den vollständigen Code des Beispiels finden Sie hier:

import unittestfrom unittest.mock 
import patchclass TestClient(unittest.TestCase):
def setUp(self): 
	self.vars_client = VarsClient()

@patch('pyvars.vars_client.VarsClient.get')
@patch('requests.post')def test_update_retry_works_eventually(self, mock_post, mock_get): 
	mock_get.side_effect = [VarsResponse(),VarsResponse()] 
	mock_post.side_effect = [requests.ConnectionError('Test error'),  
	MagicMock(status_code=200, 
	headers={'content-type':"application/json"}, 
	text=json.dumps({'status':True})) ] 

response = self.vars_client.update('test', '0') 
self.assertEqual(response, response)

@patch('pyvars.vars_client.VarsClient.get')
@patch('requests.post') 

def test_update_retry_works_eventually(self, mock_post, mock_get):

Ich fange zwei Aufrufe in der getesteten Funktion (pyvars.vars_client.VarsClient.update) ab: einen Aufruf von VarsClient.get und einen von requests.post. Da ich zwei Aufrufe abfange, erhält meine Testfunktion zwei Argumente: mock_post und mock_get. Beide sind MagicMock-Objekte. In ihrem Standardzustand tun sie nicht viel. Wir müssen ihnen also das gewünschte Antwortverhalten zuweisen.

mock_get.side_effect = [ VarsResponse(), VarsResponse()]
mock_post.side_effect = [ requests.ConnectionError('Test error'),' MagicMock(status_code=200, headers={'content-type':"application/json"}, text=json.dumps({'status':True}))]

Mit diesem Test wird geprüft, ob die Retry-Funktion irgendwann erfolgreich ist. Dazu rufe ich update mehrmals auf und führe mehrere Aufrufe von VarsClient.get und requests.post aus.

Hier richte ich die gewünschten side_effects ein. Alle Aufrufe von VarsClient.get sollen funktionieren (für diesen Test reicht es, eine leere VarsResponse zurückzugeben). Der erste Aufruf von requests.post soll eine Exception auslösen, der zweite Aufruf von requests.post soll funktionieren. Eine derart präzise Kontrolle über das Verhalten ist nur mit Mocking möglich.

response = self.vars_client.update('test', '0')self.assertEqual(response, response)

Sobald ich die side_effects eingerichtet habe, ist der Rest des Tests unkompliziert. Das Verhalten: Der erste Aufruf von requests.post schlägt fehl. Daher sollte die Retry-Funktion, die VarsClient.update umschließt, den Fehler abfangen, sodass beim zweiten Versuch alles funktioniert. Dieses Verhalten lässt sich zusätzlich überprüfen, indem Sie den Aufrufverlauf von mock_get und mock_post kontrollieren.

Fazit

Mock-Objekte korrekt zu verwenden, widerspricht unserer Intuition, Tests so realitätsnah und umfassend wie möglich zu gestalten. Doch so können wir eigenständige Tests schreiben, die schnell und ohne Abhängigkeiten ausgeführt werden. Außerdem können wir damit die Behandlung von Ausnahmen und Grenzfälle testen, die sonst unmöglich zu prüfen wären. Vor allem gibt uns das die Freiheit, uns beim Testen auf die Funktionalität unseres Codes zu konzentrieren – und nicht darauf, eine Testumgebung einzurichten. Wenn wir uns auf das Wesentliche konzentrieren, können wir die Testabdeckung verbessern und die Zuverlässigkeit unseres Codes erhöhen. Genau deshalb führen wir Tests durch.

https://docs.python.org/3/library/unittest.mock.html

IaC-Sicherheit für Entwickler

Snyk schützt Ihre Infrastructure as Code vom SDLC bis zur Laufzeit in der Cloud mit einer einheitlichen Policy-as-Code-Engine, damit jedes Team sicher entwickeln, bereitstellen und betreiben kann.

Gepostet in: