How to set mocked exception behavior on Python?

528 views Asked by At

I am using an external library (github3.py) that defines an internal exception (github3.exceptions.UnprocessableEntity). It doesn't matter how this exception is defined, so I want to create a side effect and set the attributes I use from this exception.

Tested code not-so-minimal example:

import github3

class GithubService:
    def __init__(self, token: str) -> None:
        self.connection = github3.login(token=token)
        self.repos = self.connection.repositories()

    def create_pull(self, repo_name: str) -> str:
        for repo in self.repos:
            if repo.full_name == repo_name:
                break
        try:
            created_pr = repo.create_pull(
                title="title",
                body="body",
                head="head",
                base="base",
            )
        except github3.exceptions.UnprocessableEntity as github_exception:
            extra = ""
            for error in github_exception.errors:
                if "message" in error:
                    extra += f"{error['message']} "
                else:
                    extra += f"Invalid field {error['field']}. " # testing this case
            return f"{repo_name}: {github_exception.msg}. {extra}"

I need to set the attributes msg and also errors from the exception. So I tried in my test code using pytest-mock:

@pytest.fixture
def mock_github3_login(mocker: MockerFixture) -> MockerFixture:
    """Fixture for mocking github3.login."""
    mock = mocker.patch("github3.login", autospec=True)
    mock.return_value.repositories.return_value = [
        mocker.Mock(full_name="staticdev/nope"),
        mocker.Mock(full_name="staticdev/omg"),
    ]
    return mock


def test_create_pull_invalid_field(
    mocker: MockerFixture, mock_github3_login: MockerFixture,
) -> None:
    exception_mock = mocker.Mock(errors=[{"field": "head"}], msg="Validation Failed")
    mock_github3_login.return_value.repositories.return_value[1].create_pull.side_effect = github3.exceptions.UnprocessableEntity(mocker.Mock())
    mock_github3_login.return_value.repositories.return_value[1].create_pull.return_value = exception_mock
    response = GithubService("faketoken").create_pull("staticdev/omg")

    assert response == "staticdev/omg: Validation Failed. Invalid field head."

The problem with this code is that, if you have side_effect and return_value, Python just ignores the return_value.

The problem here is that I don't want to know the implementation of UnprocessableEntity to call it passing the right arguments to it's constructor. Also, I didn't find other way using just side_effect. I also tried to using return value and setting the class of the mock and using it this way:

def test_create_pull_invalid_field(
    mock_github3_login: MockerFixture,
) -> None:
    exception_mock = Mock(__class__ = github3.exceptions.UnprocessableEntity, errors=[{"field": "head"}], msg="Validation Failed")
    mock_github3_login.return_value.repositories.return_value[1].create_pull.return_value = exception_mock
    response = GithubService("faketoken").create_pull("staticdev/omg")

    assert response == "staticdev/omg: Validation Failed. Invalid field head."

This also does not work, the exception is not thrown. So I don't know how to overcome this issue given the constraint I don't want to see the implementation of UnprocessableEntity. Any ideas here?

1

There are 1 answers

3
jeremyr On BEST ANSWER

So based on your example, you don't really need to mock github3.exceptions.UnprocessableEntity but only the incoming resp argument.

So the following test should work:

def test_create_pull_invalid_field(
    mocker: MockerFixture, mock_github3_login: MockerFixture,
) -> None:
    mocked_response = mocker.Mock()
    mocked_response.json.return_value = {
        "message": "Validation Failed", "errors": [{"field": "head"}]
    }

    repo = mock_github3_login.return_value.repositories.return_value[1]
    repo.create_pull.side_effect = github3.exceptions.UnprocessableEntity(mocked_response)
    response = GithubService("faketoken").create_pull("staticdev/omg")

    assert response == "staticdev/omg: Validation Failed. Invalid field head."

EDIT:

If you want github3.exceptions.UnprocessableEntity to be completely abstracted, it won't be possible to mock the entire class as catching classes that do not inherit from BaseException is not allowed (See docs). But you can get around it by mocking the constructor only:

def test_create_pull_invalid_field(
    mocker: MockerFixture, mock_github3_login: MockerFixture,
) -> None:
    def _initiate_mocked_exception(self) -> None:
        self.errors = [{"field": "head"}]
        self.msg = "Validation Failed"

    mocker.patch.object(
        github3.exceptions.UnprocessableEntity, "__init__", 
        _initiate_mocked_exception
    )

    repo = mock_github3_login.return_value.repositories.return_value[1]
    repo.create_pull.side_effect = github3.exceptions.UnprocessableEntity
    response = GithubService("faketoken").create_pull("staticdev/omg")

    assert response == "staticdev/omg: Validation Failed. Invalid field head."