Einführung

Jeder Data Scientist kennt es: Was als kleines Projekt mit einigen Funktionen startet wird schnell unübersichtlich, benötigt ausgelagerte Module und lässt sich nicht mehr komfortabel testen. Deshalb gibt es in Python die Library unittest.mock, die wesentliche Vorteile beim Testen bietet und aktuell der Standard für Mocking in Python ist.
Beim Mocking ermöglichen hilfreiche Klassen und Funktionen, beispielsweise die Patch-Funktion oder die Erweiterungsklasse MagicMock, Teile der Implementierung während eines Tests mit einer Dummy-Version, sogenannte Mock-Objekte, zu ersetzen. Der Entwickler muss dabei Annahmen darüber treffen, wie die Objekte in der Anwendung verwendet werden. MagicMock erweitert zusätzlich das Modul, indem es einen großen Teil der magic methods unterstützt, wie z.B. die len()-Funktion oder die str()-Funktion. Ein Mock-Funktionsaufruf gibt üblicherweise einen vordefinierten Wert zurück und simuliert somit das Verhalten des Objekts oder der Funktion, welches es ersetzt; außerdem kann ein Mock-Objekt Attribute und Methoden besitzten, ohne dass man das echte Objekt initialisieren muss.
Mocking ermöglicht uns

  • eine bessere Kontrolle beim Testen zu haben,
  • Abhängigkeiten im Code (low coupling) zu verringern
  • und schnellere Testdurchläufe durchzuführen.


In diesem Beitrag wird anhand beispielhafter Problemstellungen gezeigt, welche Möglichkeiten Mocking beim Testing bietet und in welchen Bereichen es eingesetzt werden kann. Hier lernen Sie:

  • wichtige Anwendungen, insbesondere patching, Assertions, return_value, side_effects, configure_mock
  • Unterschied zwischen objektorientiertem und funktionalem Mocking
  • konkrete Use Cases

Deswegen steigen wir direkt in ein konkretes Beispiel ein, das zeigen soll, was Mocking beim Testen in Python leisten kann.

Beispiel: Das Wochenende mocken

In diesem Beispiel soll die Funktion is_weekday() getestet werden, die uns angibt, ob der heutige Tag ein Werktag ist oder nicht. Hierfür nutzen wir die Library datetime, die uns über die Funktion datetime.date.today() das Datum des heutigen Tages liefert. Mit der Methode .weekday() erhalten wir dann einen Integer zwischen 0 und 6, wobei die Zahl 0 für Montag und die Zahl 6 für Sonntag steht. Liegt der Integer-Wert zwischen 0 und 4 liegt also ein Werktag vor und es wird True zurückgegeben, ist der Wert 5 oder 6 wird False zurückgegeben.

import datetime
print(datetime.date.today())
Ausgabe
2020-10-21
def is_weekday():
    today = datetime.date.today()
    #Python's datetime library behandelt Montag als 0 und Sonntag als 6
    return (0 <= today.weekday() < 5)
assert is_weekday() #Testet, ob heute ein Werktag ist

Um zu prüfen, ob die Funktion auch am Wochenende das richtige Ergebnis liefert, müsste man am Wochenende arbeiten, damit die Funktion datetime.date.today() ein Datum zurückgibt, dass am Wochenende liegt. Wenn man nun aber als Entwickler am Wochenende nicht arbeiten will, dann bietet Mocking die Möglichkeit, das Verhalten der Funktion selbst zu steuern. Mit Mocking kann man nämlich den Rückgabewert einer Funktion selbst definieren; dadurch fallen äußere Einflüsse - wie z.B. das Datum - weg und man kann den Code mit verschiedenen Datumsangaben testen.

from unittest.mock import Mock

#definiere einige Tage für den Test
dienstag = datetime.date(year=2019, month=1, day=1)
samstag = datetime.date(year=2019, month=1, day=5)

#Mocke datetime, um später das heutige Datum selbst zu definieren
datetime = Mock()

#definiere den Rückgabewert der Funktion .today selbst
datetime.date.today.return_value = dienstag
assert is_weekday()
datetime.date.today.return_value = samstag
assert not is_weekday()

Das obige Beispiel ist ein klassischer Use Case von Mocking: Zuerst werden verschiedene Daten gespeichert (hier: ein Datum für Dienstag und Samstag), dann wird ein Teil der Anwendung gemockt (hier: datetime) und der Rückgabewert selbst definiert. Somit konnte getestet werden, ob die Funktion is_weekday() für Werktage und Tage am Wochenende den richtigen Rückgabewert liefert - und alles in nur einem Testdurchlauf!

Weitere Anwendungen

Im Folgenden werden wichtige Anwendungen besprochen, die im Umgang mit Mocking am häufigsten verwendet werden und bei der Arbeit als Data Scientist nützliche Tools sind.

Die Anwendung side_effect

Mit der Anwendung side_effect können Mock-Objekten Iterationen, Exceptions oder Funktionen übergeben werden.
Iterations bieten die Möglichkeit, verschiedene Rückgabewerte zu definieren, um Mocking flexibler einzusetzen. Wird bei einem Test der Mock mehrmals aufgerufen, können damit nacheinander verschiedene Werte zurückgegeben werden, wodurch der Code zum einen übersichtlicher und zum anderen das Testing erleichtert wird. Dafür muss der Anwendung eine Liste übergeben werden, die die einzelnen Rückgabewerte des Mocks in der gewünschten Reihenfolge enthält.

#beispielhafte Iteration
mock = Mock()
mock.side_effect = [3,2,1]
mock(), mock(), mock()
Ausgabe
(3, 2, 1)

Besonders spannend ist auch, dass Mock-Objekte Exeptions werfen können, durch die verschiedene Fehlerquellen beim Ausführen des Codes spezifisch getestet werden können. Somit können mögliche Fehlerquellen ausgetestet werden und Troubleshooting betrieben werden. Z.B. tritt in der Data Science häufig auf, dass Datensätze am Anfang nicht korrekt eingelesen werden. Mit Mocking kann man solche Fehler vorab künstlich erzeugen, wodurch das Abfangen dieser Fehler erleichtert und der Code folglich robuster gestaltet werden kann.

#Exception
mock = Mock()
mock.side_effect = Exception("Fehler")
mock()
Ausgabe
---------------------------------------------------------------------------

Exception                                 Traceback (most recent call last)

<ipython-input-25-43732c38b044> in <module>
      2 mock = Mock()
      3 mock.side_effect = Exception("Fehler")
----> 4 mock()


~/anaconda3/lib/python3.7/unittest/mock.py in __call__(_mock_self, *args, **kwargs)
    958         # in the signature
    959         _mock_self._mock_check_sig(*args, **kwargs)
--> 960         return _mock_self._mock_call(*args, **kwargs)
    961 
    962 


~/anaconda3/lib/python3.7/unittest/mock.py in _mock_call(_mock_self, *args, **kwargs)
   1018         if effect is not None:
   1019             if _is_exception(effect):
-> 1020                 raise effect
   1021             elif not _callable(effect):
   1022                 result = next(effect)


Exception: Fehler

Im Folgenden sehen Sie, wie die Anwendung side_effect genutzt werden kann, um dem Mock Funktionen zu übergeben. Beispiel (1) und (2) zeigen, dass Funktionen entweder direkt über .side_effect oder über den Konstruktor übergeben werden können. Wichtig ist hierbei zu verstehen, dass der Mock dieselbe äußere Erscheinung aufweist wie die eigentliche Funktion. Beispiel (2) zeigt das deutlich, da hier dem Mock eine Zahl übergeben werden muss (in diesem Fall die Zahl 1), damit der Nachfolger der Zahl bestimmt werden kann.

#Beispiel (1)
mock = Mock(return_value = 3)
def some_function(*args, **kwargs):
    return 'default'

mock.side_effect = some_function
mock()

#Beispiel (2)
some_other_function = lambda value: value + 1
mock = Mock(side_effect = some_other_function)
mock(1)
Ausgabe
2

Die Anwendung configure_mock()

Die Anwendung configure_mock() erleichtert dem Entwickler, den Mock nach dem Erstellen zu modifizieren und bestimmte Eigenschaften zu überschreiben. Dabei kann die Implementierung über den Funktionsaufruf, im Konstruktor oder über return_value erfolgen. Damit lassen sich insbesondere Funktionen und Attribute des Mocks definieren, durch die man vollständig Objekte mit Dummy-Versionen ersetzen kann. Nutzt eine Methode beispielsweise das Attribut some_attribute wie unten abgebildet, dann kann der Mock dieses Attribut simulieren.

attrs = {'other.return_value':True, 'method.side_effect':KeyError}

#über Funktionsaufruf
mock = Mock()
mock.configure_mock(**attrs)
print(mock.other())

#im Konstruktor
mock = Mock(some_attribute = "eggs", **attrs)
print(mock.some_attribute)

#return_value
mock = Mock()
mock.method.return_value = 3
print(mock.method())
Ausgabe
True
eggs
3

Die Anwendung patching

Diese Anwendung ist wohl die am häufigsten verwendete, da sich mit ihr sehr übersichtlich Teile der Implementierung mocken lassen. Patching ersetzt für eine gewisse Dauer ein Objekt oder eine Funktion mit einer MagicMock-Instanz, sogenanntes funktionales bzw. objektorientiertes Mocking. Unterschieden wird beim Patchen außerdem noch zwischen

  • Decorator: Mocking gilt für den gesamten Test
  • Context Manager: Objekt/Funktion wird nur für einen Teil des Tests gemockt
from unittest.mock import patch

#Decorator
@patch('some_module.some_object')
def test_something(mocked_object):
    print(mocked_object)
    
#Context Manager
def test_something():
    with patch('some_module.some_object') as mocked_object:
        print(mocked_object)

Im obigen Beispiel sieht man gut, dass Patching den Code übersichtlicher macht und Testing wesentlich erleichtert. Im ersten Beispiel wird das Objekt some_module.some_object für den gesamten Test gemockt und dem Test als Input übergeben. Im zweiten Beispiel gilt der Mock nur für einen Teil des Tests; in diesem Teil ist das gemockte Objekt als mocked_object gespeichert und kann darüber aufgerufen werden.

Funktionales Mocking

In diesem Beispiel soll die Funktion calculate(), die eine bestimmte Berechnung durchführt, getestet werden. Problem hierbei ist, dass die externe Funktion functions.fibonacci() benötigt wird. Leider ist diese Funktion noch nicht implementiert, wir wollen aber trotzdem die Funktionalität von calculate() testen und nutzen deshalb Mocking.

def calculate(x,y):
    result = functions.fibonacci()
    return (x+y)*result
#wir mocken die Fibonacci-Funktion und ermöglichen uns dadurch die Methode trotzdem zu testen
@patch('functions.fibonacci', return_value = 10)
def test_calculate():
    assert not calculate(2,3) == 50

Ein alternativer Use Case von patching wäre, dass die Fibonacci-Funktion zur Berechnung einer großen Fibonacci Zahl viel Zeit benötigt. Möchten wir diesen Rechenschritt umgehen, um unseren Testdurchlauf schneller zu machen, dann können wir wie oben auch die Funktion mocken und einen selbst gewählten Rückgabewert definieren. Ferner könnte die Fibonacci-Funktion von einem anderen Entwickler stammen, der ständig an der Methode herumbastelt. Wir müssten dadurch ggfs. auch immer wieder unsere Implementierungen ändern, was enorme Zeit kosten kann! Mocking ist in diesem Fall eine komfortable Lösung für das Problem, da es die externe Funktion mit einem Dummy ersetzt, durch den wir unsere Anwendung mit verschiedene Fibonacci-Zahlen selbst testen können.

Objektorientiertes Mocking

Bei objektorientiertem Mocking wird ein Objekt gemockt. Als Beispiel betrachten wir nochmal die Funktion is_weekday() aus dem zweiten Abschnitt. Wir patchen nun die Library datetime.date, was uns ermöglicht, für die Methode datetime.date.today() einen selbst gewählten Rückgabewert festzulegen. Jedes Mal wenn das Objekt datetime.date aufgerufen wird, wird ein selbst gewählter Wert, in diesem Fall der 01.01.2019, zurückgegeben.

import datetime

def is_weekday():
    today = datetime.date.today()
    #Python's datetime library behandelt Montag als 0 und Sonntag als 6
    return (0 <= today.weekday() < 5)
dienstag = datetime.date(year=2019, month=1, day=1)

@patch('datetime.date', return_value = dienstag)
def test_is_weekday():
    assert is_weekday()

Ein kleiner Tipp: Patchen Sie nicht zu oft große Libraries, da manchmal Funktionen bzw. Objekte an anderen Stellen verwendet und gewisse Abhängigkeiten leicht übersehen werden. Das führt zu unnötigen Komplikationen, die man nur mühsam beim Debugging finden kann.

Assertions bei Mocking

Assertions der Mocking-Library ermöglichen dem Entwickler zu überprüfen, wie sich das Mock-Objekt in einem Test tatsächlich verhält. Beispielsweise kann man mit assert_called_once() testen, ob der Mock genau einmal aufgerufen wurde. Im folgenden Beispiel sieht man, dass der erste Assertion-Call True liefert. Die Methode method(), die in dem Abschnitt configure_mock definiert wurde, ruft das Objekt genau einmal auf. Wird die Methode jedoch ein zweites Mal aufgerufen, liefert assert_called_once() dann False, da der Mock zum zweiten Mal verwendet wird.

mock = Mock()
mock.method()
mock.method.assert_called_once()
mock.method() #hier wurde der Mock das zweite Mal aufgerufen
with Exception:
    mock.method.assert_called_once()
Ausgabe
---------------------------------------------------------------------------

AttributeError                            Traceback (most recent call last)

<ipython-input-33-1550a1ac972b> in <module>
      3 mock.method.assert_called_once()
      4 mock.method() #hier wurde der Mock das zweite Mal aufgerufen
----> 5 with Exception:
      6     mock.method.assert_called_once()


AttributeError: __enter__

Weitere Assertions:

  • assert_called(): prüft, ob der Mock mindestens einmal aufgerufen wurde
  • assert_called_once(): prüft, ob der Mock genau einmal aufgerufen wurde
  • assert_called_with(*args, **kwargs): prüft, ob der Mock mit bestimmten Attributen aufgerufen wurde
  • assert_called_once_with(*args, **kwargs): prüft, ob der Mock genau einmal mit bestimmten Attributen aufgerufen wurde
  • assert_has_calls(): prüft, ob der Mock mit bestimmten Calls aufgerufen wurde. Ist any_order True, dann ist die Reihenfolge der Calls egal.
  • assert_not_called(): prüft, ob der Mock nicht aufgerufen wurde
  • assert_any_call(*args, **kwargs): prüft, ob der Mock irgendwann mal aufgerufen wurde

Zusammenfassung

In diesem Blogpost haben Sie grundlegende Konzepte von Mocking mit Python kennengelernt. Mocking hat viele Vorteile, die man sich beim Testing nicht nur als Data Scientist zu Nutzen machen kann. Äußere Umstände, interne Abhängigkeiten oder langwierige Funktionsaufrufe können damit umgangen und das Testen somit angenehmer gestalten. Sie haben gelernt, funktional und objektorientiert zu mocken, Assertions beim Mocking zu verwenden sowie side effects sinnvoll in ihre Tests miteinfließen zu lassen. In der Dokumentation des Pakets unittest.mock finden Sie noch viele weitere interessante Möglichkeiten von Mocking.

Und nun viel Spaß beim Mocken!

Möchten Sie mehr darüber erfahren, wie wir (nicht nur) mit Python Lösungen für unsere Kunden entwickeln? Wir freuen uns auf Ihre Anfrage!