2

I need to mock pathlib.Path.open using pytest-mock.

The real open_func opens a yaml-file. The return value is a regular dict. How can I mock Path.open to just load another yaml-file called test-config.yaml?

My code is not working properly as conf will simply become a str ("test_config.yaml"). It should be a dict.

from pathlib import Path

import yaml


def open_func():
    with Path.open(Path("./config.yaml")) as f:
        return yaml.load(f, Loader=yaml.FullLoader)


def test_open_func(mocker):
    mocker.patch("pathlib.Path.open", mocker.mock_open(read_data="test_config.yaml"))
    conf = open_func()

    assert isinstance(conf, dict)

EDIT: To get closer to my real world problem, I am providing the following code. I have a class TryToMock, that basically takes two files as inputs. The method load_files simply loads these files (which are actually .yaml files) and returns the output. These .yaml files are really some configuration files.

In my unit tests, I will be calling TryToMocknumerous times through pytest's parametrize. Therefore, I would like to load the original configuration files via a fixture. Then I am able to monkeypatch some entries in my various tests before running load_files.

In order not to load the original files again, I need to mock the Path.open function in TryToMock. I would like to pass the monkeypatched yaml files instead (i.e. in the form of a dict). The difficulty is that I must discriminate between the two files. That is I can't simply mock the Path.open function with the same file content.

# TryToMock.py

from pathlib import Path
import yaml

# In my current working folder, I have to .yaml files containing the following
# content for illustrative purpose:
#
# file1.yaml = {'name': 'test1', 'file_type': 'yaml'}
# file2.yaml = {'schema': 'test2', 'currencies': ['EUR', 'USD', 'JPY']}


class TryToMock:
    def __init__(self, file_to_mock_1, file_to_mock_2):
        self._file_to_mock_1 = file_to_mock_1
        self._file_to_mock_2 = file_to_mock_2

    def load_files(self):
        with Path.open(self._file_to_mock_1) as f:
            file1 = yaml.load(f, Loader=yaml.FullLoader)

        with Path.open(self._file_to_mock_2) as f:
            file2 = yaml.load(f, Loader=yaml.FullLoader)

        return file1, file2




# test_TryToMock.py

import os
from pathlib import Path

import pytest
import yaml

from tests import TryToMock


def yaml_files_for_test(yaml_content):
    names = {"file1.yaml": file1_content, "file2.yaml": file2_content}
    return os.path.join("./", names[os.path.basename(yaml_content)])


@pytest.fixture(scope="module")
def file1_content():
    with Path.open(Path("./file1.yaml")) as f:
        return yaml.load(f, Loader=yaml.FullLoader)


@pytest.fixture(scope="module")
def file2_content():
    with Path.open(Path("./file2.yaml")) as f:
        return yaml.load(f, Loader=yaml.FullLoader)


def test_try_to_mock(file1_content, file2_content, monkeypatch, mocker):
    file_1 = Path("./file1.yaml")
    file_2 = Path("./file2.yaml")

    m = TryToMock.TryToMock(file_to_mock_1=file_1, file_to_mock_2=file_2)

    # Change some items
    monkeypatch.setitem(file1_content, "file_type", "json")

    # Mocking - How does it work when I would like to use mock_open???
    # How should the lambda function look like?
    mocker.patch(
        "pathlib.Path.open",
        lambda x: mocker.mock_open(read_data=yaml_files_for_test(x)),
    )

    files = m.load_files()
    assert files[0]["file_type"] == "json"
Andi
  • 3,196
  • 2
  • 24
  • 44
  • Is this just an example and not a real-world problem? The easy solution to this is to pass the path as a param to open_func. That eliminates your need to mock entirely. – jordanm May 12 '20 at 20:59
  • You are misunderstanding the `read_data` argument - it shall contain the _content_ of the file, not the _name_. – MrBean Bremen May 13 '20 at 06:10

1 Answers1

2

You have to provide the actual file contents to the read_data argument of mock_open. You can just create the data in your test:

test_yaml = """
foo:
  bar:
    - VAR: "MyVar"
"""

def test_open_func(mocker):
    mocker.patch("pathlib.Path.open", mocker.mock_open(read_data=test_yaml))
    conf = open_func()
    assert conf == {'foo': {'bar': [{'VAR': 'MyVar'}]}}

Or you can read the data from your test file:

def test_open_func(mocker):
    with open("my_fixture_path/test.yaml") as f:
        contents = f.read()
    mocker.patch("pathlib.Path.open", mocker.mock_open(read_data=contents))
    conf = open_func()
    assert isinstance(conf, dict)

The last case can be also re-written to replace the path argument in the open call by your test path:

def test_open_func(mocker):
    mocker.patch("pathlib.Path.open", lambda path: open("test.yaml"))
    conf = open_func()
    assert isinstance(conf, dict)

or, if you have different test files for different configs, something like:

def yaml_path_for_test(yaml_path):
    names = {
        "config.yaml": "test.yaml",
        ...
    }
    return os.path.join(my_fixture_path, names[os.path.basename(yaml_path)])

def test_open_func3(mocker):
    mocker.patch("pathlib.Path.open", lambda path: open(yaml_path_for_test(path)))
    conf = open_func()
    assert isinstance(conf, dict)

This is probably what you wanted to achieve in your test code.

UPDATE: This is related to the second part of the question (after the edit). If you have the module-scoped fixtures that preload the fixture files as in the question, you can do something like this:

def test_open_func(mocker, file1_content, file2_content):
    def yaml_files_for_test(path):
        contents = {"file1.yaml": file1_content,
                    "file2.yaml": file2_content}
        data = contents[os.path.basename(path)]
        mock = mocker.mock_open(read_data=yaml.dump(data))
        return mock.return_value

    mocker.patch("pathlib.Path.open", yaml_files_for_test)
    conf = open_func()
    assert isinstance(conf, dict)

or, if you prefer not to use nested functions:

def yaml_files_for_test(path, mocker, content1, content2):
    contents = {"file1.yaml": content1,
                "file2.yaml": content2}
    data = contents[os.path.basename(path)]
    mock = mocker.mock_open(read_data=yaml.dump(data))
    return mock.return_value


def test_open_func5(mocker, file1_content, file2_content):
    mocker.patch("pathlib.Path.open",
                 lambda path: yaml_files_for_test(path, mocker,
                                                  file2_content, file2_content))
    conf = open_func()
    assert isinstance(conf, dict)
MrBean Bremen
  • 14,916
  • 3
  • 26
  • 46
  • I got the `read_data` arg wrong. Thanks for pointing this out. – Andi May 13 '20 at 07:10
  • In my real code, I do have actually two `Path.open` statements/context managers. Each of them needs to be mocked with a different file/content. How am I supposed to do that? – Andi May 13 '20 at 07:13
  • The usage of a lambda function is a nice solution to this problem. In my real unit tests, I am calling the original function numerous times using `parametrize`. Therefore, I was thinking about loading the original yaml-files once via a fixture and subsequently use `monkeypatch.setitem` to alter its contents in different tests. As a result, I am wondering if using `mocker.mock_open(read_data=...)` wouldn't be the better way to go? How would the `lambda`function look like in that case, i.e. how can I mock different file contents for the `Path.open` function? – Andi May 13 '20 at 13:00
  • Ok, thanks for updating the question - I added yet another part to the answer. – MrBean Bremen May 13 '20 at 18:37
  • Thanks for answering the second part of my question. I highly appreciate your effort!! Your solution is working properly! However, I didn't change the fixtures in my code. I need to get back a `dict` in order to be able to comfortably edit the content via `monkeypatch.setitem`. Otherwise, I would have to mess around with editing `str`. After changing the content, I use `file1_content = yaml.dump(file1_content)` to create the "unparsed" content that is necessary for your solution to work. – Andi May 14 '20 at 07:09
  • Ah, ok, that's also a good solution - I'll edit the answer one last time.... – MrBean Bremen May 14 '20 at 07:15
  • I don't fully understand why we don't have to specify the `file` input argument for `yaml_files_for_test` when calling `mocker.patch(target="pathlib.Path.open", new=yaml_files_for_test)`. If I were to extract `yaml_files_for_test` in order to become a local function (rather than a nested function), I need to provide the input arg `file`. I am not sure how `mocker.patch(target="pathlib.Path.open", new=yaml_files_for_test)` must then look like? – Andi May 14 '20 at 10:49
  • I adapted the answer. – MrBean Bremen May 14 '20 at 13:00