Eine Einführung in das Spotten in Python
Veröffentlicht: 2022-03-11So führen Sie Unit-Tests in Python durch, ohne Ihre Geduld zu testen
In den meisten Fällen interagiert die von uns geschriebene Software direkt mit dem, was wir als „schmutzige“ Dienste bezeichnen würden. Laienhaft ausgedrückt: Dienste, die für unsere Anwendung entscheidend sind, deren Wechselwirkungen aber beabsichtigte, aber unerwünschte Seiteneffekte haben – also im Rahmen eines autonomen Testlaufs unerwünscht sind.
Zum Beispiel: Vielleicht schreiben wir eine soziale App und möchten unsere neue Funktion „Auf Facebook posten“ testen, aber nicht jedes Mal, wenn wir unsere Testsuite ausführen, tatsächlich auf Facebook posten.
Die Python-Bibliothek unittest
enthält ein Unterpaket namens unittest.mock
– oder einfach mock
, wenn Sie es als Abhängigkeit deklarieren –, das äußerst leistungsfähige und nützliche Mittel bietet, um diese unerwünschten Nebeneffekte zu verspotten und auszublenden.
Hinweis: mock
ist seit Python 3.3 neu in der Standardbibliothek enthalten; frühere Distributionen müssen die über PyPI herunterladbare Mock-Bibliothek verwenden.
Systemaufrufe vs. Python-Mocking
Um Ihnen ein weiteres Beispiel zu geben, das wir für den Rest des Artikels verwenden werden, betrachten Sie Systemaufrufe . Es ist nicht schwer zu erkennen, dass dies die besten Kandidaten für Spott sind: ob Sie ein Skript schreiben, um ein CD-Laufwerk auszuwerfen, einen Webserver, der veraltete Cache-Dateien aus /tmp
entfernt, oder einen Socket-Server, der sich an einen TCP-Port bindet, diese nennt alle Funktionen unerwünschte Nebeneffekte im Rahmen Ihrer Unit-Tests.
Als Entwickler ist es Ihnen wichtiger, dass Ihre Bibliothek die Systemfunktion zum Auswerfen einer CD (mit den richtigen Argumenten usw.) erfolgreich aufgerufen hat, anstatt dass Ihr CD-Fach bei jedem Test tatsächlich geöffnet ist. (Oder schlimmer noch, mehrmals, da mehrere Tests während eines einzelnen Komponententestlaufs auf den Auswurfcode verweisen!)
Um Ihre Komponententests effizient und leistungsfähig zu halten, müssen Sie ebenso viel „langsamen Code“ aus den automatisierten Testläufen heraushalten, nämlich Dateisystem- und Netzwerkzugriff.
Für unser erstes Beispiel werden wir einen Standard-Python-Testfall von der ursprünglichen Form in eine Form umgestalten, die mock
verwendet. Wir zeigen, wie das Schreiben eines Testfalls mit Mocks unsere Tests intelligenter und schneller macht und mehr über die Funktionsweise der Software verrät.
Eine einfache Löschfunktion
Wir alle müssen von Zeit zu Zeit Dateien aus unserem Dateisystem löschen, also schreiben wir eine Funktion in Python, die es unseren Skripten etwas erleichtert, dies zu tun.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
Offensichtlich bietet unsere rm
-Methode zu diesem Zeitpunkt nicht viel mehr als die zugrunde liegende os.remove
Methode, aber unsere Codebasis wird sich verbessern, sodass wir hier mehr Funktionalität hinzufügen können.
Lassen Sie uns einen traditionellen Testfall schreiben, dh ohne Mocks:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
Unser Testfall ist ziemlich einfach, aber jedes Mal, wenn er ausgeführt wird, wird eine temporäre Datei erstellt und dann gelöscht. Außerdem haben wir keine Möglichkeit zu testen, ob unsere rm
-Methode das Argument richtig an den os.remove
-Aufruf weitergibt. Wir können aufgrund des obigen Tests davon ausgehen , dass dies der Fall ist, aber es bleibt viel zu wünschen übrig.
Refactoring mit Python-Mocks
Lassen Sie uns unseren Testfall mit mock
umgestalten:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
Mit diesen Refactors haben wir die Funktionsweise des Tests grundlegend geändert. Jetzt haben wir einen Insider , ein Objekt, mit dem wir die Funktionalität eines anderen überprüfen können.
Mögliche Fallstricke beim Python-Spott
Eines der ersten Dinge, die auffallen sollten, ist, dass wir den Methoden-Decorator mock.patch
verwenden, um ein Objekt zu simulieren, das sich auf mymodule.os
, und dieses Mock in unsere Testfallmethode einfügen. Wäre es nicht sinnvoller, nur os
selbst zu verspotten, anstatt den Verweis darauf bei mymodule.os
?
Nun, Python ist eine Art hinterhältige Schlange, wenn es um Importe und die Verwaltung von Modulen geht. Zur Laufzeit hat das mymodule
-Modul sein eigenes os
, das in seinen eigenen lokalen Bereich im Modul importiert wird. Wenn wir also os
verspotten, werden wir die Auswirkungen des Verspottens im Modul mymodule
nicht sehen.
Das immer wieder zu wiederholende Mantra lautet:
Verspotten Sie einen Gegenstand dort, wo er verwendet wird, nicht wo er herkommt.
Wenn Sie das tempfile
-Modul für myproject.app.MyElaborateClass
, müssen Sie das Mock wahrscheinlich auf myproject.app.tempfile
, da jedes Modul seine eigenen Importe behält.
Nachdem diese Falle aus dem Weg ist, lasst uns weiter spotten.
Validierung zu „rm“ hinzufügen
Die zuvor definierte rm
Methode ist ziemlich stark vereinfacht. Wir möchten, dass es validiert, dass ein Pfad existiert und eine Datei ist, bevor es einfach blind versucht, es zu entfernen. Lassen Sie uns rm
etwas intelligenter umgestalten:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
Toll. Lassen Sie uns nun unseren Testfall anpassen, um die Abdeckung aufrechtzuerhalten.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
Unser Testparadigma hat sich komplett geändert. Wir können jetzt die interne Funktionalität von Methoden ohne Nebenwirkungen verifizieren und validieren.
Dateientfernung als Dienst mit Mock-Patch
Bisher haben wir nur mit der Bereitstellung von Mocks für Funktionen gearbeitet, aber nicht für Methoden an Objekten oder Fällen, in denen Mocks zum Senden von Parametern erforderlich sind. Lassen Sie uns zuerst Objektmethoden behandeln.
Wir beginnen mit einem Refactoring der rm
-Methode in eine Serviceklasse. Es gibt per se keine vertretbare Notwendigkeit, eine so einfache Funktion in ein Objekt zu kapseln, aber es wird uns zumindest dabei helfen, Schlüsselkonzepte in mock
zu demonstrieren. Lassen Sie uns umgestalten:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
Sie werden feststellen, dass sich in unserem Testfall nicht viel geändert hat:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
Super, jetzt wissen wir also, dass der RemovalService
wie geplant funktioniert. Lassen Sie uns einen anderen Dienst erstellen, der ihn als Abhängigkeit deklariert:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
Da wir bereits eine Testabdeckung für den RemovalService
haben, werden wir die interne Funktionalität der rm
-Methode in unseren Tests von UploadService
nicht validieren. Stattdessen testen wir einfach (natürlich ohne Nebeneffekte), dass UploadService
die Methode RemovalService.rm
aufruft , von der wir aus unserem vorherigen Testfall wissen, dass sie „einfach funktioniert“.

Dazu gibt es zwei Möglichkeiten:
- Verspotten Sie die Methode
RemovalService.rm
selbst. - Stellen Sie eine simulierte Instanz im Konstruktor von
UploadService
.
Da beide Methoden beim Komponententesten oft wichtig sind, werden wir beide überprüfen.
Option 1: Mocking-Instanzmethoden
Die mock
-Bibliothek hat einen speziellen Methoden-Decorator zum Mocken von Methoden und Eigenschaften von Objektinstanzen, den @mock.patch.object
Decorator:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
Toll! Wir haben validiert, dass der UploadService
die rm
-Methode unserer Instanz erfolgreich aufruft. Fällt Ihnen dort etwas Interessantes auf? Der Patch-Mechanismus hat in unserer Testmethode tatsächlich die rm
-Methode aller RemovalService
Instanzen ersetzt. Das bedeutet, dass wir die Instanzen tatsächlich selbst inspizieren können. Wenn Sie mehr sehen möchten, versuchen Sie, einen Haltepunkt in Ihren Spottcode einzufügen, um ein gutes Gefühl dafür zu bekommen, wie der Patch-Mechanismus funktioniert.
Mock-Patch Fallstrick: Dekorateur-Order
Wenn Sie mehrere Decorators für Ihre Testmethoden verwenden, ist die Reihenfolge wichtig und etwas verwirrend. Grundsätzlich sollten Sie bei der Zuordnung von Decorators zu Methodenparametern rückwärts vorgehen. Betrachten Sie dieses Beispiel:
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
Beachten Sie, wie unsere Parameter an die umgekehrte Reihenfolge der Dekorateure angepasst werden? Das liegt zum Teil an der Funktionsweise von Python. Bei Dekoratoren mit mehreren Methoden ist hier die Ausführungsreihenfolge im Pseudocode:
patch_sys(patch_os(patch_os_path(test_something)))
Da der Patch für sys
der äußerste Patch ist, wird er zuletzt ausgeführt und ist damit der letzte Parameter in den eigentlichen Argumenten der Testmethode. Beachten Sie dies gut und verwenden Sie beim Ausführen Ihrer Tests einen Debugger, um sicherzustellen, dass die richtigen Parameter in der richtigen Reihenfolge eingefügt werden.
Option 2: Erstellen von Scheininstanzen
Anstatt die spezifische Instanzmethode zu verspotten, könnten wir UploadService
stattdessen einfach eine verspottete Instanz mit ihrem Konstruktor bereitstellen. Ich bevorzuge Option 1 oben, da sie viel präziser ist, aber es gibt viele Fälle, in denen Option 2 effizient oder notwendig sein könnte. Lassen Sie uns unseren Test noch einmal umgestalten:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
In diesem Beispiel mussten wir nicht einmal eine Funktionalität patchen, wir erstellen einfach eine automatische Spezifikation für die RemovalService
-Klasse und fügen diese Instanz dann in unseren UploadService
ein, um die Funktionalität zu validieren.
Die Methode mock.create_autospec
erstellt eine funktional äquivalente Instanz zur bereitgestellten Klasse. Praktisch bedeutet dies, dass bei der Interaktion mit der zurückgegebenen Instanz Ausnahmen ausgelöst werden, wenn sie auf illegale Weise verwendet werden. Genauer gesagt, wenn eine Methode mit der falschen Anzahl von Argumenten aufgerufen wird, wird eine Ausnahme ausgelöst. Dies ist äußerst wichtig, wenn Refactorings stattfinden. Wenn sich eine Bibliothek ändert, brechen Tests ab, und das ist zu erwarten. Ohne die Verwendung einer automatischen Spezifikation werden unsere Tests dennoch bestanden, obwohl die zugrunde liegende Implementierung fehlerhaft ist.
Fallstrick: Die Klassen mock.Mock
und mock.MagicMock
Die mock
-Bibliothek enthält auch zwei wichtige Klassen, auf denen die meisten internen Funktionen aufbauen: mock.Mock
und mock.MagicMock
. Wenn Sie die Wahl haben, eine mock.Mock
Instanz, eine mock.MagicMock
-Instanz oder eine automatische Spezifikation zu verwenden, ziehen Sie immer die Verwendung einer automatischen Spezifikation vor, da sie hilft, Ihre Tests für zukünftige Änderungen sauber zu halten. Dies liegt daran, dass mock.Mock
und mock.MagicMock
alle Methodenaufrufe und Eigenschaftszuweisungen unabhängig von der zugrunde liegenden API akzeptieren. Betrachten Sie den folgenden Anwendungsfall:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
Wir können dies mit einer mock.Mock
Instanz wie dieser testen:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
Diese Logik scheint vernünftig, aber ändern wir die Target.apply
-Methode, um mehr Parameter zu akzeptieren:
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
Führen Sie Ihren Test erneut aus, und Sie werden feststellen, dass er immer noch besteht. Das liegt daran, dass es nicht für Ihre eigentliche API entwickelt wurde. Aus diesem Grund sollten Sie immer die create_autospec
Methode und den autospec
Parameter mit den @ @patch
und @patch.object
Dekoratoren verwenden.
Python-Mock-Beispiel: Verspotten eines Facebook-API-Aufrufs
Lassen Sie uns zum Abschluss ein besser anwendbares Python-Mock-Beispiel aus der realen Welt schreiben, eines, das wir in der Einführung erwähnt haben: das Posten einer Nachricht auf Facebook. Wir schreiben eine schöne Wrapper-Klasse und einen entsprechenden Testfall.
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
Hier ist unser Testfall, der überprüft, ob wir die Nachricht posten, ohne die Nachricht tatsächlich zu posten:
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
Wie wir bisher gesehen haben, ist es wirklich einfach, intelligentere Tests mit mock
in Python zu schreiben.
Fazit
Die mock
-Bibliothek von Python ist zwar etwas verwirrend in der Arbeit, aber ein Game-Changer für Unit-Tests. Wir haben allgemeine Anwendungsfälle für den Einstieg in die Verwendung von mock
in Unit-Tests demonstriert, und hoffentlich hilft dieser Artikel Python-Entwicklern dabei, die anfänglichen Hürden zu überwinden und hervorragenden, getesteten Code zu schreiben.