Search code examples
pythonmockingpytestdecorator

Creating a decorator to mock input() using monkeypatch in pytest


End goal: I want to be able to quickly mock the input() built-in function in pytest, and replace it with an iterator that generates a (variable) list of strings. This is my current version, which works:

from typing import Callable
import pytest


def _create_patched_input(str_list: list[str]) -> Callable:
    str_iter = iter(str_list.copy())

    def patched_input(prompt: str) -> str:  # has the same signature as input
        val = next(str_iter)
        print(prompt + val, end="\n"),
        return val

    return patched_input


@pytest.fixture
def _mock_input(monkeypatch, input_string_list: list[str]):
    patched_input = _create_patched_input(input_string_list)
    monkeypatch.setattr("builtins.input", patched_input)


def mock_input(f):
    return pytest.mark.usefixtures("_mock_input")(f)

# Beginning of test code
def get_name(prompt: str) -> str:
    return input(prompt)


@mock_input
@pytest.mark.parametrize(
    "input_string_list",
    (["Alice", "Bob", "Carol"], ["Dale", "Evie", "Frank", "George"]),
)
def test_get_name(input_string_list):
    for name in input_string_list:
        assert get_name("What is your name?") == name

However, this feels incomplete for a few reasons:

  • It requires the parameter name in the parameterize call to be input_string_list, which feels brittle.
  • If I move the fixtures into another function, I need to import both mock_input and _mock_input.

What would feel correct to me is to have a decorator (factory) that can be used like @mock_input(strings), such that you could use it like

@mock_input(["Alice", "Bob", "Carol"])
def test_get_name():
    ....

or, more in line with my use case,

@pytest.mark.parametrize(
    "input_list", # can be named whatever
    (["Alice", "Bob", "Carol"], ["Dale", "Evie", "Frank", "George"]),
)
@mock_input(input_list)
def test_get_name():
    ....

The latter I don't think you can do, as pytest wont recognize it as a fixture. What's the best way to do this?


Solution

  • I'd use indirect parametrization for mock_input, since it cannot work without receiving parameters. Also, I would refactor mock_input into a fixture that does passing through the arguments it receives, performing the mocking on the way. For example, when using unittest.mock.patch():

    import pytest
    from unittest.mock import patch
    
    @pytest.fixture
    def inputs(request):
        texts = requests.param  # ["Alice", "Bob", "Carol"] etc
        with patch('builtins.input', side_effect=texts):
            yield texts
    

    Or, if you want to use monkeypatch, the code gets a bit more complex:

    @pytest.fixture
    def inputs(monkeypatch, request):
        texts = requests.param
        it = iter(texts)
    
        def fake_input(prefix):
            return next(it)
    
        monkeypatch.setattr('builtins.input', fake_input)
        yield texts
    

    Now use inputs as test argument and parametrize it indirectly:

    @pytest.mark.parametrize(
        'inputs',
        (["Alice", "Bob", "Carol"], ["Dale", "Evie", "Frank", "George"]),
        indirect=True
    )
    def test_get_name(inputs):
        for name in inputs:
            assert get_name("What is your name?") == name