Beispiele

Mocking von datetime.datetime

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()
    

Mocking der CLI

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()