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
  1. Dann definieren wir zwei Testtage:

    5monday = datetime(year=2021, month=10, day=11)
    6saturday = datetime(year=2021, month=10, day=16)
    
  2. Nun definieren wir eine Methode zur Überprüfung der Arbeitstage, wobei die datetime-Bibliothek von Python Montage als 0 und Sonntage als 6 behandelt:

     9def is_workingday():
    10    today = datetime.today()
    11    return 0 <= today.weekday() < 5
    
  3. Dann mocken wir datetime:

    14datetime = Mock()
    
  4. 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:

  1. In diesem Fall wird das Attribut __version__ von items für die Dauer des with-Blocks durch "100.0.0" ersetzt.

  2. Anschließend verwenden wir items_cli(), um unsere CLI-Anwendung mit dem Befehl "version" aufzurufen. Wenn die Methode version() aufgerufen wird, ist das Attribut __version__ jedoch nicht der ursprüngliche String, sondern der String, den wir mit mock.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

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: