Examples

Mocking datetime.datetime

We wanted to start with a simple example to check whether the working days from Monday to Friday are calculated correctly.

  1. First, we import datetime.datetime and Mock:

    1from datetime import datetime
    2from unittest.mock import Mock
    
  2. Next, we’ll define two test days:

    5monday = datetime(year=2021, month=10, day=11)
    6saturday = datetime(year=2021, month=10, day=16)
    
  3. Now let’s define a method to check for working days, bearing in mind that Python’s datetime library treats Mondays as 0 and Sundays as 6:

     9def is_workingday():
    10    today = datetime.today()
    11    return 0 <= today.weekday() < 5
    
  4. Then let’s mock datetime:

    14datetime = Mock()
    
  5. Finally, we test our two mock objects:

    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 the CLI

When testing the Tasks CLI, we’ll also look at how the CliRunner provided by Typer helps with testing. Typer offers a testing interface that allows us to invoke our application without having to resort to subprocess.run(), as in the brief capsys example. This is useful because we cannot simulate what runs in a separate process. So, in tests/cli/conftest.py, we can simply pass our application cusy.tasks.cli.app and a list of strings representing the command to the invoke() function of our runner: more specifically, we use shlex.split(command_string) to parse the commands, for example, list -o 'veit' into ["list", "-o", "veit"], and can then capture and return the output.

 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

We can then simply use this fixture to test, for example, the version in tests/cli/test_version.py:

from cusy import tasks


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

Mocking attributes

Let’s look at how we can use mocking to ensure that, for example, three-digit version numbers from tasks.__version__() are also output correctly via the CLI. To do this, we’ll use mock.patch.object() as a context manager:

 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 our test code, we import tasks. The resulting tasks object is what we are going to patch. The call to mock.patch.object(), used as a context manager within a with block, returns a mock object that is cleaned up after the with block:

  1. In this case, the __version__ attribute of tasks is replaced with "100.0.0" for the duration of the with block.

  2. We then use tasks_cli() to run our CLI application with the version command. However, when the version() method is called, the __version__ attribute is not the original string, but the string we replaced it with using mock.patch.object().

Mocking classes and methods

In src/cusy/tasks/cli.py, we have defined config() as follows:

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

tasks_db() is a context manager that returns a tasks.TasksDB object. The returned object is then used as db to call db.path(). So we need to mock two things here: tasks.TasksDB and one of its methods, path(). Let’s start with the class:

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)

Let’s make sure it really works:

$ 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 ===============================

Great, now we just need to move the database mock into a fixture, as we’ll need it in lots of test methods:

@pytest.fixture()
def mock_tasksdb():
    with mock.patch.object(tasks, "TasksDB") as MockTasksDB:
        yield MockTasksDB.return_value

This fixture mocks the TasksDB object and returns the return_value, so that tests can use it to substitute values for things like path:

def test_mock_tasksdb(tasks_cli, mock_tasksdb):
    mock_tasksdb.path.return_value = "/foo/"
    result = runner.invoke(app, ["config"])
    assert result.stdout.rstrip() == "/foo/"

Alternatively, the @mock.patch() decorator can also be used to mock classes or objects. In the following examples, the output of os.listdir is mocked. For this, db_path does not need to exist on the file system:

import os
from unittest import mock


@mock.patch("os.listdir", mock.MagicMock(return_value="db_path"))
def test_listdir():
    assert "db_path" == os.listdir()

Another option is to define the return value separately:

@mock.patch("os.listdir")
def test_listdir(mock_listdir):
    mock_listdir.return_value = "db_path"
    assert "db_path" == os.listdir()