1

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?

jeremyr
  • 425
  • 4
  • 12
staticdev
  • 2,950
  • 8
  • 42
  • 66
  • 1
    You're supposed to use either the [return_value](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.return_value) attribute if you want your mocked function to consistently return the same value or the [side_effect](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.side_effect) attribute if you want your mocked function to return an iterable or raise an exception. A mocked function which defines both makes no sense. Also, what are you trying to test exactly? Testing doesn't really make sense if you are mocking every inputs :/ – jeremyr Oct 06 '20 at 18:27
  • Your minimal example is not self-contained - in your code you call `repo.create_pull` (and use a non-existent variable `github_repo`), but in the test you mock `github3.login` - did you want to mock `github3.repos.ShortRepository`? – MrBean Bremen Oct 06 '20 at 19:03
  • You are right @MrBeanBremen I updated the code to make it more complete. Hope it is better now. – staticdev Oct 06 '20 at 20:37
  • You are right @jeremyr.. from this definition I needed side_effect since I need an exception. But, I need to find a way to use the exception attributes to make my output message. – staticdev Oct 06 '20 at 20:40

1 Answers1

1

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."

staticdev
  • 2,950
  • 8
  • 42
  • 66
jeremyr
  • 425
  • 4
  • 12
  • this solution works but relies on the implementation of the exception. I don't know if there is other way arround. If no one elses come to a solution that doesn't use it, I will accept this as the correct one. – staticdev Oct 06 '20 at 23:51
  • @staticdev I edited the answer to cover your use case. Abstracting exception is always a bit tricky as it cannot be caught properly if not derived from BaseException, but it is possible to only mock the constructor method. – jeremyr Oct 07 '20 at 00:30
  • I think this second version is a more "pure" unit test although a bit trickier as you said, but that is perfect to me. I would also add the first one marked as integration/end-to-end. Thanks a lot @jeremyr. – staticdev Oct 07 '20 at 08:32