pythonunit-testingmockingpython-unittest.mockpytest-mock

Python mock - mocking class method that modifies class attributes


I currently have the following basic Python class that I want to test:

class Example:

    def run_steps(self):
        self.steps = 0

        while self.steps < 4:
            self.step()
    
    def step(self):
        # some expensive API call
        print("wasting time...")
        time.sleep(1000)

        self.steps += 1

As you can see, the step() method contains an expensive API call so I want to mock it with another function that avoids the expensive API call but still increments self.steps. I found that this is possible by doing this (as seen from here):

def mock_step(self):
    print("skip the wasting time")
    self.steps += 1

# This code works!
def test(mocker):
    example = Example()
    mocker.patch.object(Example, 'step', mock_step)

    example.run_steps()

I simply create a function called mock_step(self) that avoids the API call, and I patch the original slow step() method with the new mock_step(self) function.

However, this causes a new problem. Since the mock_step(self) function is not a Mock object, I can't call any of the Mock methods on it (such as assert_called() and call_count()):

def test(mocker):
    example = Example()
    mocker.patch.object(Example, 'step', mock_step)

    example.run_steps()

    # this line doesn't work
    assert mock_step.call_count == 4

To solve this issue, I have tried to wrap mock_step with a Mock object using the wraps parameter:

def test(mocker):
    example = Example()

    # this doesn't work
    step = mocker.Mock(wraps=mock_step)
    mocker.patch.object(Example, 'step', step)

    example.run_steps()

    assert step.call_count == 4

but then I get a different error saying mock_step() missing 1 required positional argument: 'self'.

So from this stage I am not sure how I can assert that step() has been called exactly 4 times in run_steps().


Solution

  • There are several solutions to this, the simplest is probably using a standard mock with a side effect:

    def mock_step(self):
        print("skip the wasting time")
        self.steps += 1
    
    
    def test_step(mocker):
        example = Example()
        mocked = mocker.patch.object(Example, 'step')
        mocked.side_effect = lambda: mock_step(example)
        example.run_steps()
        assert mocked.call_count == 4
    

    side_effect can take a callable, so you can both use a standard mock and the patched method.