Search code examples
pythonunit-testingmockingpytestmonkeypatching

Mock/Patch specific object method inside a class method


I know the title is quite confusing, but for the sake of clarity, I created a sample of what I have to transpose:

Let's say I have this code in class_file.py

class CarDealership:
    def allow_car_out_of_the_dealership(self, car):
        logger.info(f"Driving out of the dealership")
        
        drivers_license = car.driver.wallet.get_drivers_license()

        if drivers_license:
            try:
                allow_car_removal(car, drivers_license)
            except:
                deny_car_removal(car, drivers_license)

I'm trying to test a situation where the method deny_car_removal is called. get_valid_license() is a method specific to driver.wallet and is not otherwise imported or referenced in class_file.py in any way, shape or form.

The problem is that the code goes a completely different way if get_valid_license does not return anything valid in the real situation. I'm trying to patch the drivers_license so I can eventually bring it to the point I need.

I'm guessing test_class_file.py should look something like this:

class CarDealershipTests(BaseTestCase):
    @patch('class_file.CarDealership.deny_car_removal')
    def test_deny_car_removal(self):
        # something here
        self.assertTrue(mock_deny_car_removal.called)

A few things I have tried from looking at other StackOverflow answers but didn't work:

  • In the test setup, mock a full driver instance inside the mocked car, like so:
    def setUp(self) -> None:
        self._create_car
    
    def _create_car(self):
        car = Car()
        car.driver = MagicMock()
  • Tried using @patch.object(class_file.CarDealership.allow_car_out_of_the_dealership, "car.driver.wallet.get_valid_license")
  • Tried using @patch.object(class_file.CarDealership.allow_car_out_of_the_dealership, "drivers_license")
  • Tried using @patch("class_file.CarDealership.allow_car_out_of_the_dealership.car.driver.wallet.get_valid_license")
  • Tried using @patch("class_file.CarDealership.allow_car_out_of_the_dealership.drivers_license")

Solution

  • Suppose you have a file with the following contents as you have shown:

    import logging
    
    logger = logging.getLogger(__name__)
    
    class CarRemovalFailure(BaseException):
        pass
    
    
    class CarDealership:
        def allow_car_out_of_the_dealership(self, car):
            logger.info("Driving out of the dealership")
            
            drivers_license = car.driver.wallet.get_drivers_license()
    
            if drivers_license:
                try:
                    allow_car_removal(car, drivers_license)
                except CarRemovalFailure:
                    deny_car_removal(car, drivers_license)
    

    In order to have deny_car_removal be called, we will need allow_car_removal to throw an Exception. This is easily done by using the side_effect attribute of the Mock object.

    I didn't incorporate any setup/tear-down up in this test but instead am illustrating how side_effect works and how it ends up having deny_car_removal be called.

    from unittest.mock import MagicMock, patch
    from class_file import CarDealership, CarRemovalFailure
    
    
    @patch("class_file.allow_car_removal")
    @patch("class_file.deny_car_removal")
    def test_deny_car(mock_deny, mock_allow):
        mock_allow.side_effect = CarRemovalFailure("Failed!")
        car = MagicMock()
        cd = CarDealership()
    
        cd.allow_car_out_of_the_dealership(car)
    
        car.driver.wallet.get_drivers_license.assert_called_once()
        mock_deny.assert_called_once()
    

    When run it outputs the following:

    ============================= test session starts ==============================
    platform darwin -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
    rootdir: ****
    collected 1 item                                                               
    
    tests/test_car.py .                                                      [100%]
    
    ============================== 1 passed in 0.05s ===============================
    

    In your example you might not want to patch deny_car_removal as you are trying to test the logic, but since I don't know what that might be I patched it on my end. The point of that was to illustrate that in fact the function does get called.