Simulation externer Abhängigkeiten in Pytest mit pytest-mock
Wenhao Wang
Dev Intern · Leapcell

Einführung
In der modernen Softwareentwicklung arbeiten Anwendungen selten isoliert. Sie interagieren häufig mit externen Diensten wie REST-APIs, Drittanbieterbibliotheken und Datenbanken. Obwohl diese Abhängigkeiten für die Funktionalität einer Anwendung entscheidend sind, stellen sie erhebliche Herausforderungen für Unit- und Integrationstests dar. Die Abhängigkeit von tatsächlichen externen Systemen während Tests kann zu langsamen Tests, Instabilität aufgrund von Netzwerkproblemen oder Dienstausfällen führen und sogar Kosten für die API-Nutzung verursachen. Hier kommt das Konzept des "Mockings" ins Spiel. Durch die Simulation dieser externen Abhängigkeiten können wir gesteuerte, vorhersagbare und schnelle Testumgebungen schaffen. Dieser Artikel untersucht, wie pytest-mock – ein leistungsstarkes Pytest-Plugin – genutzt werden kann, um externe API- und Datenbankaufrufe effektiv zu simulieren und so robuste und effiziente Tests Ihrer Python-Anwendungen zu gewährleisten.
Grundlegende Konzepte verstehen
Bevor wir uns der praktischen Implementierung mit pytest-mock zuwenden, lassen Sie uns einige grundlegende Konzepte klären:
- Mocking: Beim Testen wird beim Mocking ein reales Objekt oder eine Funktion durch ein Ersatzobjekt ersetzt, das das Verhalten des Originals simuliert. Dieses Ersatzobjekt, ein "Mock-Objekt" oder einfach ein "Mock", ermöglicht es Ihnen, Rückgabewerte zu steuern, Ausnahmen auszulösen und Interaktionen zu überwachen, alles ohne den ursprünglichen Code tatsächlich aufzurufen.
- Stubbing: Oft synonym mit Mocking verwendet, bezieht sich Stubbing speziell auf die Bereitstellung vorprogrammierter Antworten auf Methodenaufrufe während eines Tests. Der Hauptzweck eines Stubs ist es, vordefinierte Antworten auf Aufrufe während des Tests zu liefern, nicht das Verhalten zu überprüfen.
- Spying (Abhorchen): Beim Spying wird ein reales Objekt oder eine Funktion umhüllt, wobei dessen ursprüngliches Verhalten beibehalten wird. Der Spy zeichnet dann alle Interaktionen mit dem umhüllten Objekt auf, sodass Sie überprüfen können, ob bestimmte Methoden mit bestimmten Argumenten aufgerufen wurden.
pytest-mockkonzentriert sich hauptsächlich auf Mocking, obwohl seine Fähigkeiten erweitert werden können, um Interaktionen zu beobachten. - Unit Testing: Konzentriert sich auf das Testen einzelner Einheiten oder Komponenten einer Softwareanwendung isoliert. Mocking ist hier entscheidend, um die zu testende Einheit von ihren Abhängigkeiten zu isolieren.
- Integration Testing: Überprüft die Interaktionen zwischen verschiedenen Einheiten oder Komponenten einer Anwendung. Während Integrationstests einige reale Abhängigkeiten beinhalten können, kann Mocking immer noch für extrem volatile oder kostspielige externe Dienste verwendet werden.
pytest-mock ist eine Pytest-Fixture, die eine praktische Hülle um die integrierte Bibliothek unittest.mock von Python bietet. Sie lässt sich nahtlos in das leistungsstarke Testframework von Pytest integrieren und bietet eine saubere und intuitive Möglichkeit, Mock-Objekte in Ihren Tests zu verwalten.
Simulation externer Abhängigkeiten mit pytest-mock
Das Kernprinzip der Verwendung von pytest-mock besteht darin, die tatsächlichen Objekte oder Funktionen, die mit externen Diensten interagieren, durch gesteuerte Mock-Objekte zu ersetzen. Dies geschieht typischerweise mit der Methode mocker.patch() der pytest-mock-Fixture.
Betrachten wir ein praktisches Beispiel. Stellen Sie sich vor, wir haben eine Python-Funktion, die Benutzerdaten von einer Remote-API abruft, und eine weitere, die Daten in einer Datenbank speichert.
Unser Anwendungscode (z. B. app.py):
import requests import sqlite3 class User: def __init__(self, user_id, name, email): self.user_id = user_id self.name = name self.email = email def __repr__(self): return f"User(id={self.user_id}, name={self.name}, email={self.email})" def fetch_user_from_api(user_id): """Ruft Benutzerdaten von einer Platzhalter-API ab.""" api_url = f"https://jsonplaceholder.typicode.com/users/{user_id}" try: response = requests.get(api_url) response.raise_for_status() # Löst eine Ausnahme für ungültige Statuscodes aus data = response.json() return User(data['id'], data['name'], data['email']) except requests.exceptions.RequestException as e: print(f"Fehler beim Abrufen des Benutzers: {e}") return None def save_user_to_db(user): """Speichert Benutzerdaten in einer SQLite-Datenbank.""" conn = None try: conn = sqlite3.connect('users.db') cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL ) ''') cursor.execute("INSERT INTO users (id, name, email) VALUES (?, ?, ?)", (user.user_id, user.name, user.email)) conn.commit() return True except sqlite3.Error as e: print(f"Fehler beim Speichern des Benutzers in der DB: {e}") return False finally: if conn: conn.close() def get_and_save_user(user_id): """Ruft einen Benutzer ab und speichert ihn in der Datenbank.""" user = fetch_user_from_api(user_id) if user: return save_user_to_db(user) return False
Schreiben wir nun Tests für get_and_save_user, ohne tatsächlich jsonplaceholder.typicode.com aufzurufen oder eine echte users.db-Datei zu erstellen.
Testen mit pytest-mock (z. B. test_app.py):
Stellen Sie zunächst sicher, dass Sie pytest und pytest-mock installiert haben:
pip install pytest pytest-mock requests
import pytest from unittest.mock import MagicMock import app # Unser Anwendungscode # Testfall 1: Erfolgreiches Abrufen und Speichern def test_get_and_save_user_success(mocker): # Mocken der requests.get-Methode mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { 'id': 1, 'name': 'Leanne Graham', 'email': 'sincere@april.biz' } mocker.patch('requests.get', return_value=mock_response) # Mocken der sqlite3.connect-Methode und ihrer zugehörigen Cursor-Funktionen mock_conn = MagicMock() # Sicherstellen, dass commit und close aufrufbar sind, aber für den Mock nichts tun mock_conn.commit.return_value = None mock_conn.close.return_value = None mock_conn.cursor.return_value.execute.return_value = None # Für CREATE TABLE und INSERT mocker.patch('sqlite3.connect', return_value=mock_conn) # Aufrufen der zu testenden Funktion result = app.get_and_save_user(1) # Assertions assert result is True # Überprüfen, ob requests.get aufgerufen wurde mocker.call_args_list[0].assert_called_once_with('https://jsonplaceholder.typicode.com/users/1') # Achtung: Dies ist kein gültiger Aufruf von mocker.call_args_list. Es müsste mock_response.assert_called_once_with(...) lauten, oder alternativ den Pfad zur Funktion, die es aufruft. # Überprüfen, ob sqlite3.connect aufgerufen wurde mock_conn.cursor.assert_called_once() mock_conn.cursor.return_value.execute.assert_any_call( ''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL ) ''' ) mock_conn.cursor.return_value.execute.assert_any_call( "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", (1, 'Leanne Graham', 'sincere@april.biz') ) mock_conn.commit.assert_called_once() mock_conn.close.assert_called_once() # Testfall 2: API-Aufruf schlägt fehl (z. B. Netzwerkfehler) def test_get_and_save_user_api_failure(mocker): # Mocken von requests.get, um eine Ausnahme auszulösen mocker.patch('requests.get', side_effect=requests.exceptions.RequestException("Network error")) mocker.patch('sqlite3.connect') # Selbst wenn die API fehlschlägt, wird sichergestellt, dass die DB nicht berührt wird result = app.get_and_save_user(1) assert result is False mocker.call_args_list[0].assert_called_once_with('https://jsonplaceholder.typicode.com/users/1') # Siehe Anmerkung oben # Sicherstellen, dass sqlite3.connect NICHT aufgerufen wurde app.sqlite3.connect.assert_not_called() # Testfall 3: Datenbank-Speicherung schlägt fehl def test_get_and_save_user_db_failure(mocker): # Mocken einer erfolgreichen API-Antwort mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { 'id': 1, 'name': 'Leanne Graham', 'email': 'sincere@april.biz' } mocker.patch('requests.get', return_value=mock_response) # Mocken von sqlite3.connect, um während des Commits einen Fehler auszulösen mock_conn = MagicMock() mock_conn.cursor.return_value.execute.return_value = None mock_conn.commit.side_effect = sqlite3.Error("Database write error") mocker.patch('sqlite3.connect', return_value=mock_conn) result = app.get_and_save_user(1) assert result is False mocker.call_args_list[0].assert_called_once_with('https://jsonplaceholder.typicode.com/users/1') # Siehe Anmerkung oben # Überprüfen von DB-Verbindungs- und Commit-Versuchen mock_conn.cursor.assert_called_once() mock_conn.commit.assert_called_once() mock_conn.close.assert_called_once() # Sicherstellen, dass die Verbindung auch bei Fehlern geschlossen wird
Erläuterung des Codebeispiels:
mocker-Fixture:pytest-mockstellt diemocker-Fixture bereit, die automatisch das Setup und den Abbau von Mock-Objekten übernimmt. Wenn ein Test endet, werden alle mitmockererstellten Patches automatisch rückgängig gemacht, wodurch Testverschmutzung verhindert wird.mocker.patch('modul.objekt', ...): Dies ist die primäre Methode zum Mocking.- Das erste Argument (
'requests.get') ist ein String, der den vollständig qualifizierten Pfad des zu mockenden Objekts darstellt. Es ist wichtig, dort zu patchen, wo das Objekt nachgeschlagen wird, nicht unbedingt dort, wo es definiert ist. In unseremapp.pywirdrequests.getdirekt aufgerufen, daher patchen wirrequests.get. - Für die Datenbank muss
sqlite3.connectaufgerufen werden. Es ist wichtig, die Mocks für Objekte zu verketten, die vom anfänglichen Mock zurückgegeben werden. Zum Beispiel ermöglichtmock_conn.cursor.return_value.executedas Mocken derexecute-Methode descursor-Objekts, dasconnectzurückgeben würde.
- Das erste Argument (
return_value: Dieser Attribut eines Mock-Objekts gibt an, was es zurückgeben soll, wenn es aufgerufen wird. Fürmock_response.jsonsetzen wir seinenreturn_valueauf ein Wörterbuch, das die JSON-Antwort der API nachahmt.side_effect: Anstelle einesreturn_valuekannside_effectverwendet werden, um den Mock eine Ausnahme auslösen oder eine Funktion aufrufen zu lassen, wenn er aufgerufen wird. Dies ist nützlich, um Fehlerbedingungen zu simulieren, wie intest_get_and_save_user_api_failureundtest_get_and_save_user_db_failuregezeigt.MagicMock: Dies ist eine vielseitige Mock-Klasse ausunittest.mock, die Objekte erstellt, die Attribute und Methoden automatisch erstellen, wenn darauf zugegriffen wird. Dies ist äußerst nützlich, um komplexe Objekte wie HTTP-Antworten oder Datenbankverbindungen zu simulieren, bei denen Sie sich nicht um jedes einzelne Attribut oder jede Methode kümmern.- Assertions auf Mocks: Nach dem Aufruf der gemockten Funktion können wir Assertionsmethoden des Mock-Objekts verwenden, um dessen Verhalten zu überprüfen:
mock.assert_called_once_with(...): Stellt sicher, dass der Mock genau einmal mit bestimmten Argumenten aufgerufen wurde.mock.assert_any_call(...): Stellt sicher, dass der Mock mindestens einmal mit bestimmten Argumenten aufgerufen wurde.mock.assert_not_called(): Stellt sicher, dass der Mock nie aufgerufen wurde.mock.call_args_list: Bietet eine Liste aller an den Mock gestellten Aufrufe.
Fortgeschrittene Szenarien und bewährte Praktiken
- Klassen mocken: Sie können ganze Klassen mocken, um Mock-Instanzen zurückzugeben. Zum Beispiel würde
mocker.patch('app.User', return_value=MagicMock())dazu führen, dassapp.User()einMagicMock-Objekt zurückgibt. - Context-Manager: Für eine granulare Mocking innerhalb einer
with-Anweisung kannmocker.patch.object()verwendet werden, oft innerhalb eineswith-Blocks für temporäres Mocking. - Fixture-Scopes: Denken Sie an die Fixture-Scopes von Pytest.
mockerhat normalerweise den Funktions-Scope, was bedeutet, dass Patches nach jedem Test entfernt werden. Wenn Sie einen persistenten Mock benötigen (z. B. für ein ganzes Modul), ziehen Sieautouse=Truefür eineFixture in Betracht, diemockerverwendet, oder konfigurieren Sie ihn globaler. Funktions-bezogene Mocks werden jedoch im Allgemeinen zur Isolierung und Übersichtlichkeit bevorzugt. - Wann mocken vs. nicht mocken: Mocking eignet sich am besten für externe, unvorhersehbare oder teure Abhängigkeiten. Für internen, stabilen Code ist es oft besser, mit echten Implementierungen zu testen, um Integrationsprobleme aufzudecken.
- Übermäßiges Mocking: Seien Sie vorsichtig, Ihr eigenes System nicht zu übermäßig zu mocken. Wenn Sie zu viel mocken, können Ihre Tests zwar bestanden werden, Ihre reale Anwendung könnte jedoch immer noch fehlschlagen, da die Mocks das reale Verhalten nicht genau widerspiegeln oder Sie den Mock selbst testen und nicht Ihre Logik.
Fazit
pytest-mock bietet eine elegante und effektive Lösung, um Ihren Python-Code während des Testens von externen Abhängigkeiten zu isolieren. Wenn Sie lernen, mocker.patch() strategisch einzusetzen und die Fähigkeiten von unittest.mock-Objekten verstehen, können Sie die Geschwindigkeit, Zuverlässigkeit und Wartbarkeit Ihrer Testsuite erheblich verbessern. Durch das Akzeptieren des Mockings können sich Ihre Tests ausschließlich auf die Logik der zu prüfenden Einheit konzentrieren, was zu robusterer und qualitativ hochwertigerer Software führt. Das effektive Simulieren von Abhängigkeiten ist der Schlüssel zum Schreiben von widerstandsfähigen und effizienten Tests.

