Mock

In diesem Kapitel werden wir die CLI testen. Hierfür werden wir die mock-Bibliothek verwenden, das seit Python 3.3 als Teil der Python-Standardbibliothek unter dem Namen unittest.mock ausgeliefert wird.

Objekte, die nicht real sind, können entweder Dummies, Fakes, Stubs, Mocks oder Spies sein. Sie sind alle sog. Test-Doubles. Mit dem pytest-eigenen monkeypatch-Fixture und unittest.mock solltet ihr jedoch über alle Funktionen verfügen, die ihr benötigt.

Die drei Kernfunktionalitäten von unittest.mock sind:

Mock

Die Mock-Klasse kann zur Simulation eines beliebigen Objekts verwendet werden.

MagickMock

Unterklasse von Mock, die alle magischen Methoden enthält, z. B. __str__, __len__ usw.

patch-Methode

In einem bestimmten Modul wird ein Objekt gesucht und ersetzt durch ein anderes Objekt.

Beispiel

Zunächst wollten wir mit einem einfachen Beispiel starten und überprüfen, ob die Arbeitstage von Montag bis Freitag korrekt ermittelt werden.

  1. Zunächst importieren wir datetime.datetime und Mock:

    1from datetime import datetime
    2from unittest.mock import Mock
    
  2. Dann definieren wir zwei Testtage:

    5monday = datetime(year=2021, month=10, day=11)
    6saturday = datetime(year=2021, month=10, day=16)
    
  3. 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
    
  4. Dann mocken wir datetime:

    14datetime = Mock()
    
  5. Schließlich testen wir unsere beiden Mock-Objekte:

    17def test_workinngday():
    18    # Mock .today() to return Tuesday
    19    datetime.today.return_value = monday
    20    # Test Tuesday is a weekday
    21    assert is_workingday()
    
    24def test_no_workingday():
    25    # Mock .today() to return Saturday
    26    datetime.today.return_value = saturday
    27    # Test Saturday is not a weekday
    28    assert not is_workingday()
    

Testen mit Typer

Für die Tests der Tasks-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 cusy.tasks.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

 from cusy import tasks

 runner = CliRunner()


 @pytest.fixture()
 def tasks_cli(db_path, monkeypatch, tasks_db):
     monkeypatch.setenv("ITEMS_DB_DIR", db_path.as_posix())

     def run_cli(command_string):
         command_list = shlex.split(command_string)
         result = runner.invoke(tasks.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:

from cusy import tasks


def test_version(tasks_cli):
    assert tasks_cli("version") == tasks.__version__

Siehe auch

Typer Learn Testing

Mocking von Attributen

Schauen wir uns an, wie wir Mocking verwenden können, um sicherzustellen, dass z. B. auch dreistellige Versionsnummern von tasks.__version__() korrekt über die CLI ausgegeben werden. Hierfür werden wir mock.patch.object() als Kontextmanager verwenden:

 from unittest import mock

 from cusy import tasks


 def test_mock_version(tasks_cli):
     with mock.patch.object(tasks, "__version__", "100.0.0"):
         assert tasks_cli("version") == tasks.__version__

In unserem Testcode importieren wir tasks. Das resultierende tasks-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 tasks für die Dauer des with-Blocks durch "100.0.0" ersetzt.

  2. Anschließend verwenden wir tasks_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/cusy/tasks/cli.py haben wir config() folgendermaßen definiert:

def config():
    """List the path to the Tasks db."""
    with tasks_db() as db:
        print(db.path())

tasks_db() ist ein Kontextmanager, der ein tasks.TasksDB-Objekt zurückgibt. Das zurückgegebene Objekt wird dann als db verwendet, um db.path() aufzurufen. Wir sollten hier also zwei Dinge zu mocken: tasks.TasksDB und eine seiner Methoden, path(). Beginnen wir mit der Klasse:

from unittest import mock

from cusy import tasks


def test_mock_tasksdb(tasks_cli):
    with mock.patch.object(tasks, "TasksDB") as MockTasksDB:
        mock_db_path = MockTasksDB.return_value.path.return_value = "/foo/"
        assert tasks_cli("config") == str(mock_db_path)

Lasst und sicherstellen, dass es wirklich funktioniert:

$ uv run pytest -v -s tests/cli/test_config.py::test_mock_tasksdb
============================= 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_tasksdb 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_tasksdb():
    with mock.patch.object(tasks, "TasksDB") as MockTasksDB:
        yield MockTasksDB.return_value

Diese Fixture mockt das TasksDB-Objekt und gibt den return_value zurück, so dass Tests ihn verwenden können, um Dinge wie path zu ersetzen:

def test_mock_tasksdb(tasks_cli, mock_tasksdb):
    mock_tasksdb.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_tasksdb():
     with mock.patch.object(tasks, "TasksDB", autospec=True) as MockTasksDB:
         yield MockTasksDB.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 tasks 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 tasks_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_task() mit einem Task-Objekt auf:

def test_add_with_owner(mock_tasksdb, tasks_cli):
    tasks_cli("add some task -o veit")
    expected = tasks.Task("some task", owner="veit", state="todo")
    mock_tasksdb.add_task.assert_called_with(expected)

Wenn add_task() 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:

$ uv run 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_task(Task(summary='some task', owner='Veit', state='todo', id=None))
E           Actual: add_task(Task(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 Tasks-CLI Fehlerbedingungen korrekt behandelt. Hier ist z.B. die Implementierung des Löschbefehls:

@app.command()
def delete(task_id: int):
    """Remove task in db with given id."""
    with tasks_db() as db:
        try:
            db.delete_task(task_id)
        except tasks.InvalidTaskId:
            print(f"Error: Invalid task id {task_id}")

Um zu testen, wie die CLI mit einer Fehlerbedingung umgeht, können wir so tun, als ob delete_task() 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_tasksdb, tasks_cli):
    mock_tasksdb.delete_task.side_effect = tasks.api.InvalidTaskId
    out = tasks_cli("delete 42")
    assert "Error: Invalid task 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

Bemerkung

Auch bei agentischer Software-Entwicklung versuchen wir, Mocking so weit wie möglich zu vermeiden:

AGENTS.md
- Prefer testing real code where possible. Use mocks and `monkeypatch` when absolute necessary. Try to avoid mocking as much as possible.

Siehe auch

Mocking vermeiden mit Tests auf mehreren Ebenen

Wir können die Tasks-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(tasks_db, tasks_cli):
    tasks_cli("add some task -o veit")
    expected = tasks.Task("some task", owner="veit", state="todo")
    all = tasks_db.list_tasks()
    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:

$ uv run 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 Tasks-Liste geparst werden, um den korrekten Datenbankinhalt zu überprüfen.

In der API gibt add_task() einen Index zurück und bietet eine get_task(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 tasks get index oder tasks info index hinzufügen, damit wir ein Task abrufen können, anstatt tasks 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 tasks add eine Ausgabe hinzufügen, die etwas sagt wie Task 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: