1

How it started

I'm testing a class, ClassToTest, that makes API calls using atlassian-python-api. The tests are going to ensure that ClassToTest performs correctly with the data it gets back from the API. Many of the atlassian-python-api API calls use instantiated classes which inherit from the same base class or group of top-level classes.

I'd like to write tests that will expose breaks in the API contract if the wrong data is returned or API calls fail, while also testing the class I wrote to ensure it does the correct things with the data returned from the API. In order to do this, I was hoping to use unittest.mock.patch("path.to.Comment", autospec=True) to copy the API spec into the MagicMock, but I don't believe it's working properly.

For the purposes of the question, ClassToTest is not that important; what I am aiming to solve is how to setup and configure the pytest fixtures in a way that I can use them to mimic the API endpoints that will return the data that ClassToTest will act upon. Ideally I'd like to reuse the fixtures without having patch conflicts. I've included relevant code from ClassToTest for illustrative purposes here:

class_to_test.py:

from atlassian.bitbucket import Cloud
from typing import NamedTuple
# these are hardcoded constants that work with the production API
from src.constants import (
    PULL_REQUEST_ID,
    REPOSITORY,
    WORKSPACE,
)


CommentType = NamedTuple("CommentType", [("top_level", str), ("inline", str)])

class ClassToTest:
    def _get_token(self):
        """this returns a token of type(str)"""

    def __init__(self, workspace, repository, pull_request_id):
        self.active_comments = None
        self.environment = sys.argv[1]
        self.comment_text = CommentType(
            top_level=r"top_level_comment text", inline=r"inline_comment text"
        )
        self.cloud = Cloud(token=self._get_token(), cloud=True)
        self.workspace = self.cloud.workspaces.get(workspace)
        self.repository = self.cloud.repositories.get(workspace, repository)
        self.pull_request = self.repository.pullrequests.get(id=pull_request_id)

    def _get_active_comments(self):
        """Returns a list of active (non-deleted) comments"""
        return [
            c for c in self.pull_request.comments() if c.data["deleted"] is False
        ]
    # a few more methods here

def main():
    instance = ClassToTest(WORKSPACE, REPOSITORY, PULL_REQUEST_ID)
    # result = instance.method() for each method I need to call.
    # do things with each result

if __name__ == "__main__":
    main()

The class has methods that retrieve comments from the API (_get_active_comments, above), act on the retrieved comments, retrieve pull requests, and so on. What I am trying to test is that the class methods act correctly on the data received from the API, so I need to accurately mock data returned from API calls.

How it's going

I started with a unittest.Testcase style test class and wanted the flexibility of pytest fixtures (and autospec), but removed Testcase entirely when I discovered that pytest fixtures don't really work with it. I'm currently using a pytest class and conftest.py as follows:

/test/test_class_to_test.py:

import pytest

from unittest.mock import patch

from src.class_to_test import ClassToTest


@pytest.mark.usefixtures("mocked_comment", "mocked_user")
class TestClassToTest:
    # We mock Cloud here as ClassToTest calls it in __init__ to authenticate with the API
    # _get_token retrieves an access token for the API; since we don't need it, we can mock it
    @patch("src.test_class_to_test.Cloud", autospec=True)
    @patch.object(ClassToTest, "_get_token").
    def setup_method(self, method, mock_get_token, mock_cloud):
        mock_get_token.return_value = "token"
        self.checker = ClassToTest("WORKSPACE", "REPOSITORY", 1)

    def teardown_method(self, method):
        pass


    def test_has_top_level_and_inline_comments(self, mocked_comment, mocked_pull_request):
        mock_top_comment = mocked_comment(raw="some text to search for later")

        assert isinstance(mock_top_comment.data, dict)
        assert mock_top_comment.data["raw"] == "some text to search for later"
        # the assert below this line is failing
        assert mock_top_comment.user.account_id == 1234

conftest.py:

import pytest
from unittest.mock import patch, PropertyMock

from atlassian.bitbucket.cloud.common.comments import Comment
from atlassian.bitbucket.cloud.common.users import User


@pytest.fixture()
def mocked_user(request):
    def _mocked_user(account_id=1234):
        user_patcher = patch(
            f"atlassian.bitbucket.cloud.common.users.User", spec_set=True, autospec=True
        )
        MockUser = user_patcher.start()
        data = {"type": "user", "account_id": account_id}
        url = "user_url"
        user = MockUser(data=data, url=url)

        # setup mocked properties
        mock_id = PropertyMock(return_value=account_id)
        type(user).id = mock_id
        mockdata = PropertyMock(return_value=data)
        type(user).data = mockdata
        request.addfinalizer(user_patcher.stop)
        return user
    return _mocked_user


@pytest.fixture()
def mocked_comment(request, mocked_user):
    def _mocked_comment(raw="", inline=None, deleted=False, user_id=1234):
        comment_patcher = patch(
            f"atlassian.bitbucket.cloud.common.comments.Comment", spec_set=True, autospec=True
        )
        MockComment = comment_patcher.start()
        data = {
            "type": "pullrequest_comment",
            "user": mocked_user(user_id),
            "raw": raw,
            "deleted": deleted,
        }
        if inline:
            data["inline"] = {"from": None, "to": 1, "path": "src/code_issues.py"}
            data["raw"] = "this is an inline comment"
        comment = MockComment(data)
        # setup mocked properties
        mockdata = PropertyMock(return_value=data)
        type(comment).data = mockdata
        # mockuser = PropertyMock(return_value=mocked_user(user_id))
        # type(comment).user = mockuser
        request.addfinalizer(comment_patcher.stop)
        return comment
        
    return _mocked_comment

The problem I am encountering is that the assert mock_top_comment.user.account_id == 1234 line fails when running the test, with the following error:

>       assert mock_top_comment.user.account_id == 1234
E       AssertionError: assert <MagicMock name='Comment().user.account_id' id='4399290192'> == 1234
E        +  where <MagicMock name='Comment().user.account_id' id='4399290192'> = <MagicMock name='Comment().user' id='4399634736'>.account_id
E        +    where <MagicMock name='Comment().user' id='4399634736'> = <NonCallableMagicMock name='Comment()' spec_set='Comment' id='4399234928'>.user

How do I get the mock User class to attach to the mock Comment class in the same way that the real API makes it work? Is there something about autospec that I'm missing, or should I be abandoning unittest.mock.patch entirely and using something else?

Extra credit (EDIT: in retrospect, this may be the most important part)

I'm using mocked_comment as a pytest fixture factory and want to reuse it multiple times in the same test (for example to create multiple mocked Comments returned in a list). So far, each time I've tried to do that, I've been met with the following error:

    def test_has_top_level_and_inline_comments(self, mocked_comment, mocked_pull_request):
        mock_top_comment = mocked_comment(raw="Some comment text")
>       mock_inline_comment = mocked_comment(inline=True)

...

test/conftest.py:30: in _mocked_comment
    MockComment = comment_patcher.start()
/opt/homebrew/Cellar/python@3.10/3.10.8/Frameworks/Python.framework/Versions/3.10/lib/python3.10/unittest/mock.py:1585: in start
    result = self.__enter__()

...

>               raise InvalidSpecError(
                    f'Cannot autospec attr {self.attribute!r} from target '
                    f'{target_name!r} as it has already been mocked out. '
                    f'[target={self.target!r}, attr={autospec!r}]')
E               unittest.mock.InvalidSpecError: Cannot autospec attr 'Comment' from target 'atlassian.bitbucket.cloud.common.comments' as it has already been mocked out. [target=<module 'atlassian.bitbucket.cloud.common.comments' from '/opt/homebrew/lib/python3.10/site-packages/atlassian/bitbucket/cloud/common/comments.py'>, attr=<MagicMock name='Comment' spec_set='Comment' id='4398964912'>]

I thought the whole point of a pytest fixture factory was to be reusable, but I believe that using an autospec mock complicates things quite a bit. I don't want to have to hand copy every detail from the API spec into the tests, as that will have to be changed if anything in the API changes. Is there a solution for this that involves automatically and dynamically creating the necessary classes in the mocked API with the correct return values for properties?

One thing I'm considering is separating the testing into two parts: API contract, and ClassToTest testing. In this way I can write the tests for ClassToTest without relying on the API and they will pass as long as I manipulate the received data correctly. Any changes to the API will get caught by the separate contract testing tests. Then I can use non-factory fixtures with static data for testing ClassToTest.

For now though, I'm out of ideas on how to proceed with this. What should I do here? Probably the most important thing to address is how to properly link the User instance with the Comment instance in the fixtures so that my method calls in test work the same way as they do in production. Bonus points if we can figure out how to dynamically patch multiple fixtures in a single test.

I've started looking at this answer, but given the number of interconnected classes and properties, I'm not sure it will work without writing out a ton of fixtures. After following the directions and applying them to the User mock inside the Comment mock, I started getting the error in the Extra Credit section above, where autospec couldn't be used as it has already been mocked out.

Mark Arce
  • 41
  • 5
  • 1
    1st : try separating fixture from the mock. Get the mock working without fixture first, THEN worry about how to establish it in the fixture. You can always call a function in your test that provides you with the mocked object. I love fixtures, I love mock. I often try to keep them seperate for these type of problems. 2nd : I generally advise against patching `ClassToTest` in your unit test. The purpose of the test is it to ensure the class functions as expected. patching the class alters the original. Patch the underlying calls instead, it's safer. – Marcel Wilson Dec 15 '22 at 23:17
  • @MarcelWilson I do not plan to patch ClassToTest in unit tests; all of the patching is intended for Comment, User, and PullRequest. Those 3 classes all return data that ClassToTest will act on, and that data should be simulated with a fixture. I was considering subclassing each of the 3 classes with new classes for each use case I need in every test. This still doesn't solve the problem of having access to the User property on Comment in a mocked Comment, though. – Mark Arce Dec 15 '22 at 23:46
  • I'm confused. "I do not plan to patch ClassToTest in unit tests". In `TestClassToTest` there is a call to patch `@patch.object(ClassToTest, "_get_token").`. This is a unittest for `ClassToTest`. Am I missing something? – Marcel Wilson Dec 16 '22 at 15:10
  • Ah yeah, that method is patched because it makes a call to `os.environ.get`. I initially tried to patch `os.environ` and ran into issues, and figured I would test `_get_token` some other way if needed, maybe just by directly setting the value I'm looking for in `os.environ` before running the tests. In any case, that's not what I am looking for help with. – Mark Arce Dec 16 '22 at 18:59
  • I completely rewrote the fixtures (in fact, I'm no longer using pytest fixtures, technically) as classes that return instances of the objects under test, and those mocked objects are working fine. But that still doesn't solve the problem of losing the ability to `autospec` the classes and have the tests fail if the API changes. – Mark Arce Dec 20 '22 at 21:22
  • Any chance this code is public? – Marcel Wilson Dec 21 '22 at 20:45
  • Sorry, it's not. The Bitbucket Cloud API and the atlassian-python-api are the only public parts of it. – Mark Arce Dec 22 '22 at 00:10
  • Ok, so I don't know what your new code looks like, but it would appear your mock comment doesn't have the user set to the mockuser. Try adding `comment.user = mockuser` in your setup of the mock comment. – Marcel Wilson Dec 22 '22 at 15:45

0 Answers0