Mock¶
In diesem Kapitel werden wir die CLI
testen. Hierfür werden wir das mock-Paket
verwenden, das seit Python 3.3 als Teil der Python-Standardbibliothek unter dem
Namen unittest.mock
ausgeliefert wird. Für ältere Versionen von Python könnt
ihr sie installieren mit:
$ . bin/activate
$ python -m pip install mock
C:> Scripts\activate.bat
C:> python -m pip install mock
Mock-Objekte werden manchmal auch als Test-Doubles, Fakes oder Stubs bezeichnet. Mit dem pytest-eigenen monkeypatch-Fixture und mock solltet ihr über alle Funktionen verfügen, die ihr benötigt.
Beispiel¶
Zunächst wollten wir mit einem einfachen Beispiel starten und überprüfen, ob die Arbeitstage von Montag bis Freitag korrekt ermittelt werden.
Zunächst importieren wir datetime.datetime
und Mock
:
1from datetime import datetime 2from unittest.mock import Mock
Dann definieren wir zwei Testtage:
5monday = datetime(year=2021, month=10, day=11) 6saturday = datetime(year=2021, month=10, day=16)
Nun definieren wir eine Methode zur Überprüfung der Arbeitstage, wobei die datetime-Bibliothek von Python Montage als
0
und Sonntage als6
behandelt:9def is_workingday(): 10 today = datetime.today() 11 return 0 <= today.weekday() < 5
Dann mocken wir datetime:
14datetime = Mock()
Schließlich testen wir unsere beiden Mock-Objekte:
17datetime.today.return_value = monday 18# Test Tuesday is a weekday 19assert is_workingday()
21datetime.today.return_value = saturday 22# Test Saturday is not a weekday 23assert not is_workingday()
Testen mit Typer¶
Für die Tests der Items-CLI werden wir uns auch ansehen, wie der von Typer bereitgestellte CliRunner
beim Testen hilft.
Typer bietet eine Testschnittstelle, womit wir unsere Anwendung aufrufen können,
ohne, wie in dem kurzen capsys-Beispiel auf
subprocess.run()
zurückgreifen zu müssen. Das ist gut, weil wir
nicht simulieren können, was in einem separaten Prozess läuft. So können wir in
tests/cli/conftest.py
der invoke()
-Funktion unseres runner
nur
unsere Anwendung items.cli.app
und eine Liste von Strings übergeben, die den
Befehl darstellt: genauer wandeln wir mit shlex.split(command_string)()
die Befehle, z.B. list -o "veit"
in
["list", "-o", "veit"]
um und können die Ausgabe dann abfangen und
zurückgeben.
import shlex
import pytest
from typer.testing import CliRunner
import items
runner = CliRunner()
@pytest.fixture()
def items_cli(db_path, monkeypatch, items_db):
monkeypatch.setenv("ITEMS_DB_DIR", db_path.as_posix())
def run_cli(command_string):
command_list = shlex.split(command_string)
result = runner.invoke(items.cli.app, command_list)
output = result.stdout.rstrip()
return output
return run_cli
Anschließend können wir diese Fixture einfach verwenden um z.B. die Version in tests/cli/test_version.py
zu testen:
import items
def test_version(items_cli):
assert items_cli("version") == items.__version__
Mocking von Attributen¶
Schauen wir uns an, wie wir Mocking verwenden können, um sicherzustellen, dass
z.B. auch dreistellige Versionsnummern von
items.__version__()
korrekt über die CLI ausgegeben werden. Hierfür werden
wir mock.patch.object()
als Kontextmanager verwenden:
from unittest import mock
import items
def test_mock_version(items_cli):
with mock.patch.object(items, "__version__", "100.0.0"):
assert items_cli("version") == items.__version__
In unserem Testcode importieren wir items
. Das resultierende items-Objekt
ist das, was wir patchen werden. Der Aufruf von mock.patch.object()
, der
als Kontextmanager innerhalb eines
with
-Blocks verwendet wird, gibt ein Mock-Objekt zurück, das nach dem
with
-Block aufgeräumt wird:
In diesem Fall wird das Attribut
__version__
vonitems
für die Dauer deswith
-Blocks durch"100.0.0"
ersetzt.Anschließend verwenden wir
items_cli()
, um unsere CLI-Anwendung mit dem Befehl"version"
aufzurufen. Wenn die Methodeversion()
aufgerufen wird, ist das Attribut__version__
jedoch nicht der ursprüngliche String, sondern der String, den wir mitmock.patch.object()
ersetzt haben.
Mocking von Klassen und Methoden¶
In src/items/cli.py
haben wir config()
folgendermaßen definiert:
def config():
"""List the path to the Items db."""
with items_db() as db:
print(db.path())
items_db()
ist ein Kontextmanager, der
ein items.ItemsDB
-Objekt zurückgibt. Das zurückgegebene Objekt wird dann als
db
verwendet, um db.path()
aufzurufen. Wir sollten hier also zwei
Dinge zu mocken: items.ItemsDB
und eine seiner Methoden, path()
.
Beginnen wir mit der Klasse:
from unittest import mock
import items
def test_mock_itemsdb(items_cli):
with mock.patch.object(items, "ItemsDB") as MockItemsDB:
mock_db_path = MockItemsDB.return_value.path.return_value = "/foo/"
assert items_cli("config") == str(mock_db_path)
Lasst und sicherstellen, dass es wirklich funktioniert:
$ pytest -v -s tests/cli/test_config.py::test_mock_itemsdb
============================= test session starts ==============================
...
configfile: pyproject.toml
plugins: cov-4.1.0, Faker-19.11.0
collected 1 item
tests/cli/test_config.py::test_mock_itemsdb PASSED
============================== 1 passed in 0.04s ===============================
Prima, nun müssen wir nur noch den Mock für die Datenbank in eine Fixture verschieben, denn wir werden ihn in vielen Testmethoden brauchen:
@pytest.fixture()
def mock_itemsdb():
with mock.patch.object(items"ItemsDB") as MockItemsDB:
yield MockItemsDB.return_value
Diese Fixture mockt das ItemsDB
-Objekt und gibt den return_value
zurück,
so dass Tests ihn verwenden können, um Dinge wie path
zu ersetzen:
def test_mock_itemsdb(items_cli, mock_itemsdb):
mock_itemsdb.path.return_value = "/foo/"
result = runner.invoke(app, ["config"])
assert result.stdout.rstrip() == "/foo/"
Alternativ kann zum Mocken von Klassen oder Objekten auch der
@mock.patch()
-Dekorator verwendet werden. In den folgenden Beispielen wird
die Ausgabe von os.listdir
gemockt. Dazu muss db_path
nicht im
Dateisystem vorhanden sein:
import os
from unittest import mock
@mock.patch("os.listdir", mock.MagicMock(return_value="db_path"))
def test_listdir():
assert "db_path" == os.listdir()
Eine weitere Alternative ist, den Rückgabewert separat zu definieren:
@mock.patch("os.listdir")
def test_listdir(mock_listdir):
mock_listdir.return_value = "db_path"
assert "db_path" == os.listdir()
Mocks synchronisieren mit autospec
¶
Mock-Objekte sind in der Regel als Objekte gedacht, die anstelle der echten
Implementierung verwendet werden. Standardmäßig werden sie jedoch jeden Zugriff
akzeptieren. Wenn das echte Objekt beispielsweise start(index)()
zulässt,
sollen unsere Mock-Objekte ebenfalls start(index)()
zulassen. Dabei gibt
es jedoch ein Problem. Mock-Objekte sind standardmäßig zu flexibel: sie
würden auch stort()
oder andere falsch geschriebene, umbenannte oder
gelöschte Methoden oder Parameter akzeptieren. Dabei kann es im Laufe der Zeit
zum sog. Mock-Drift kommen, wenn sich die Schnittstelle,
die ihr nachbildet, ändert, euer Mock in eurem Testcode jedoch nicht. Diese Form
des Mock-Drifts kann durch das Hinzufügen von autospec=True
zum Mock während
der Erstellung gelöst werden:
@pytest.fixture()
def mock_itemsdb():
with mock.patch.object(items"ItemsDB", autospec=True) as MockItemsDB:
yield MockItemsDB.return_value
Üblicherweise wird dieser Schutz mit autospec
immer eingebaut. Die einzige
mir bekannte Ausnahme ist, wenn die Klasse oder das Objekt, das gemockt wird,
dynamische Methoden hat oder wenn Attribute zur Laufzeit hinzugefügt werden.
Siehe auch
Die Python-Dokumentation hat einen großen Abschnitt über autospec
:
Autospeccing.
Aufruf überprüfen mit assert_called_with()
¶
Bisher haben wir die Rückgabewerte einer Mocking-Methode verwendet, um
sicherzustellen, dass unser Anwendungscode mit den Rückgabewerten richtig
umgeht. Aber manchmal gibt es keinen nützlichen Rückgabewert, z.B. bei items add some tasks -o veit
. In diesen Fällen
können wir das Mock-Objekt fragen, ob es korrekt aufgerufen wurde. Nach dem
Aufruf von items_cli("add some tasks -o veit")()
wird nicht die API
verwendet, um zu prüfen, ob das Element in die Datenbank gelangt ist, sondern
ein Mock, um sicherzustellen, dass die CLI die richtige API-Methode korrekt
aufgerufen hat. Die Implementierung des Befehls add()
ruft schließlich
db.add_item()
mit einem Item
-Objekt auf:
def test_add_with_owner(mock_itemsdb, items_cli):
items_cli("add some task -o veit")
expected = items.Item("some task", owner="veit", state="todo")
mock_itemsdb.add_item.assert_called_with(expected)
Wenn add_item()
nicht aufgerufen wird oder mit dem falschen Typ oder dem
falschen Objektinhalt aufgerufen wird, schlägt der Test fehl. Wenn wir
z.B. in expected
den String "Veit"
groß
schreiben, aber nicht im CLI-Aufruf, erhalten wir folgende Ausgabe:
$ pytest -s tests/cli/test_add.py::test_add_with_owner
============================= test session starts ==============================
...
configfile: pyproject.toml
plugins: cov-4.1.0, Faker-19.11.0
collected 1 item
tests/cli/test_add.py F
...
> raise AssertionError(_error_message()) from cause
E AssertionError: expected call not found.
E Expected: add_item(Item(summary='some task', owner='Veit', state='todo', id=None))
E Actual: add_item(Item(summary='some task', owner='veit', state='todo', id=None))
...
=========================== short test summary info ============================
FAILED tests/cli/test_add.py::test_add_with_owner - AssertionError: expected call not found.
============================== 1 failed in 0.08s ===============================
Siehe auch
Es gibt eine ganze Reihe von Varianten von assert_called()
. Eine
vollständige Liste und Beschreibung erhaltet ihr in
unittest.mock.Mock.assert_called.
Wenn die einzige Möglichkeit zum Testen darin besteht, den korrekten Aufruf
sicherzustellen, erfüllen die verschiedenen assert_called*()
-Methoden
ihren Zweck.
Fehlerbedingungen erstellen¶
Lasst uns nun überprüfen, ob die Items-CLI Fehlerbedingungen korrekt behandelt. Hier ist z.B. die Implementierung des Löschbefehls:
@app.command()
def delete(item_id: int):
"""Remove item in db with given id."""
with items_db() as db:
try:
db.delete_item(item_id)
except items.InvalidItemId:
print(f"Error: Invalid item id {item_id}")
Um zu testen, wie die CLI mit einer Fehlerbedingung umgeht, können wir so tun,
als ob delete_item()
eine Exception erzeugt, indem wir dem Mock-Objekt die
Exception dem Attribut side_effect
des Mock-Objekts zuweisen, etwa so:
def test_delete_invalid(mock_itemsdb, items_cli):
mock_itemsdb.delete_item.side_effect = items.api.InvalidItemId
out = items_cli("delete 42")
assert "Error: Invalid item id 42" in out
Das ist alles, was wir brauchen, um die CLI zu testen: Mocking von Rückgabewerten, Überprüfen der Aufrufe von Mock-Funktionen und das Mocking von Exceptions. Es gibt jedoch noch eine ganze Reihe weiterer Mocking-Techniken, die wir nicht behandelt haben. Lest also unbedingt unittest.mock — mock object library, wenn ihr Mocking ausgiebig nutzen möchtet.
Grenzen des Mocking¶
Eines der größten Probleme bei der Verwendung von Mocks besteht darin, dass wir bei in einem Test nicht mehr das Verhalten, sondern die Implementierung testen. Dies ist jedoch nicht nur zeitaufwändig, sondern auch gefährlich: Ein gültiges Refactoring z.B. das Ändern eines Variablennamens, kann Tests zum Scheitern bringen, wenn diese bestimmte Variable gemockt wurde. Wir wollen jedoch, dass unsere Tests nur dann fehlschlagen, wenn es Brüche im Verhalten gibt, nicht jedoch nur bei Codeänderungen.
Manchmal ist Mocking jedoch der einfachste Weg, Exceptions oder Fehlerbedingungen zu erzeugen und sicherzustellen, dass euer Code diese korrekt behandelt. Es gibt auch Fälle, in denen das Testen von Verhalten unzumutbar ist, wie z.B. beim Zugriff auf eine Zahlungs-API oder beim Senden von E-Mails. In diesen Fällen ist es eine gute Option zu testen, ob euer Code eine bestimmte API-Methode zum richtigen Zeitpunkt und mit den richtigen Parametern aufruft.
Siehe auch
Hynek Schlawack: “Don’t Mock What You Don’t Own”
Mocking vermeiden mit Tests auf mehreren Ebenen¶
Wir können die Items-CLI auch ohne Mocks testen indem wir auch die API verwenden. Dabei werden wir nicht die API testen, sondern sie nur verwenden, um das Verhalten von Aktionen zu überprüfen, die über die CLI ausgeführt werden. Das Beispiel test_add_with_owner können wir auch folgendermaßen testen:
def test_add_with_owner(items_db, items_cli):
items_cli("add some task -o veit")
expected = items.Item("some task", owner="veit", state="todo")
all = items_db.list_items()
assert len(all) == 1
assert all[0] == expected
Mocking testet die Implementierung der Befehlszeilenschnittstelle und stellt sicher, dass ein API-Aufruf mit bestimmten Parametern erfolgt. Beim Mixed-Layer-Ansatz wird das Verhalten getestet, um sicherzustellen, dass das Ergebnis unseren Vorstellungen entspricht. Diese Ansatz ist viel weniger ein Change-Detector und hat eine größere Chance, während eines Refactorings gültig zu bleiben. Interessanterweise sind die Tests auch etwa doppelt so schnell:
$ pytest -s tests/cli/test_add.py::test_add_with_owner
============================= test session starts ==============================
...
configfile: pyproject.toml
plugins: cov-4.1.0, Faker-19.11.0
collected 1 item
tests/cli/test_add.py .
============================== 1 passed in 0.03s ===============================
Wir könnten Mocking auch auf eine andere Weise vermeiden. Wir könnten das Verhalten vollständig über die CLI testen. Dazu müsste möglicherweise die Ausgabe der Items-Liste geparst werden, um den korrekten Datenbankinhalt zu überprüfen.
In der API gibt add_item()
einen Index zurück und bietet eine
get_item(index)()
-Methode, die beim Testen hilft. Beide Methoden sind in
der CLI nicht vorhanden, könnten es aber sein. Wir könnten vielleicht die
Befehle items get index
oder items info index
hinzufügen, damit wir ein
Item abrufen können, anstatt items list für
alles verwenden zu müssen.
list
unterstützt auch bereits Filterung. Vielleicht würde das Filtern nach
index
funktionieren, anstatt einen neuen Befehl hinzuzufügen. Und wir
könnten items add
eine Ausgabe hinzufügen, die etwas sagt wie Item
hinzugefügt bei Index 3. Diese Änderungen würden in die Kategorie Design for
Testability fallen. Sie scheinen auch keine tiefen Eingriffe in die
Schnittstelle zu sein und sollten vielleicht in zukünftigen Versionen
berücksichtigt werden.
Plugins zur Unterstützung von Mocking¶
Wir haben uns bisher auf die direkte Verwendung von mock konzentriert. Es gibt jedoch viele Plugins, die
beim Mocking helfen, wie z.B. pytest-mock, das eine mocker
-Fixture
bereitstellt. Ein Vorteil ist, dass das Fixture nach sich selbst aufräumt, so
dass ihr keinen with
-Block verwenden müsst, wie wir es in unseren Beispielen
getan haben.
Es gibt auch einige spezielle Mocking-Bibliotheken:
Für das Mocking von Datenbankzugriffen eignen sich
Zum Testen von HTTP-Servern könnt ihr pytest-httpserver verwenden.
Zum Mocken von requests könnt ihr responses oder betamax verwenden.
Weitere Tools für verschiedene Anforderungen sind