Mocking hardware with Python Mock

I’ve got a ton of Raspberry Pi projects all with some degree of completion (usually closer to proof of concept than being complete). Raspberry Pis are great, but it can be a bit of a pain to test code for them when it relies on hardware and hardware libraries. Python has a great Mock library that can be utililized to handle the hardware requirements, allowing tests to be written and run anywhere.

Mocking

Mocking is a way to fake some interaction that we want to make. This is very helpful in testing something that integrates with a 3rd party service such as another API. Through mocking the 3rd party service, we can validate that our code will operate as we expect without testing that third party (or better yet being charged for using it in testing).

Example

For a simple example, we’ll use a class that is simply called MotorRunner. The MotorRunner class relies on the RPi.GPIO library to control the Pulse Width Modulation (PWM) of a motor from a GPIO pin on the Raspberry Pi. We could SSH in to the Raspberry Pi and write our code in vim/nano/emacs, but I really do prefer to use my already set up development environment. The problem that we have is the library will only import successfully on Raspberry Pi hardware.

$ python
Python 3.6.5 (default, Apr  4 2018, 15:09:05) 
[GCC 7.3.1 20180130 (Red Hat 7.3.1-2)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import RPi.GPIO as GPIO
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/dan/Projects/mockhardware/.venv/lib64/python3.6/site-packages/RPi/GPIO/__init__.py", line 23, in <module>
    from RPi._GPIO import *
RuntimeError: This module can only be run on a Raspberry Pi!

Really, that is fine. We could code on a non Raspberry Pi, transfer the files over via rsync, scp, thumbdrive, etc, or even better, have unittests handle the testing as we’re making changes.

The MotorRunner class

The MotorRunner class is pretty simple. At the initialization of the class we set some basic parameters. When we want to run the motor, we can then call the spin_motor method, if it failes it will write to stderr. The parameters used can be reviewed in the API documentation of the RPi.GPIO library, in the interest of brevity I won’t be going over them in here.

import sys
import time

import RPi.GPIO as GPIO

class MotorRunner:
    def __init__(self, spin_time=1.65, gpio_pin=18, frequency=50):
        self.spin_time = spin_time
        self.gpio_pin = gpio_pin
        self.freq = frequency
        self.p = None

    def _init_gpio(self):
        GPIO.setwarnings(False)
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(self.gpio_pin, GPIO.OUT)

    def spin_motor(self):
        try:
            self.init_gpio()
            GPIO.PWM(self.gpio_pin, self.freq)
            self.p.start(11)
            time.sleep(self.spin_time)
            self.p.stop()
        except Exception as e:
            sys.stderr.write("Error in running\n {}".format(e))
        finally:
            GPIO.cleanup()

Testing the library

This library is sufficient for running a motor connected to a Raspberry Pi, but the point of this post is to figure out how to write and test this on a non Raspberry Pi Device. Python Unittest to the rescue.

I’ve decided to do just a couple simple test cases to make sure this actually works. These are:

  • Ensure the class can be imported and created
  • Ensure that the motor PWM method is called when calling spin_motor
  • Ensure that non-default parameters are successfully handled

Mock patching

Mock had a great function to patch a library so that rather than using the library specified, it’s a Mock object instead. We can then control that Mock object to have specific returns, side effects, or just about any behavior we want, and we can look at attributes such as whether (or how many times) that object was called, and with what parameters.

Changes required

The best way I found to actually mock the hardware requires a few changes in our code. This isn’t a bad thing and only related to testing, because it also more gracefully handles errors if we run our class outside of unittests.

First, we’re going to create a global variable to determine whether or not our system can run RPi.GPIO just based on the import.

GPIO_ENABLED = False

try:
    import RPi.GPIO as GPIO
    GPIO_ENABLED = True
except RuntimeError:
    # can only be run on RPi
    import RPi as GPIO

Next, in the library we’re going to set this variable as a class attribute, and only try to use that library if it is available.:

...
class MotorRunner:
    def __init__(self, spin_time=1.65, gpio_pin=18, frequency=50):
        self._GPIO_ENABLED = GPIO_ENABLED
        self.spin_time = spin_time
...

Finally, we add a check for that variable when the call to spin the motor actually occurs.

...
        try:
            if self._GPIO_ENABLED:
                self._init_gpio()
...

That results in our class now looking like:

import sys
import time

GPIO_ENABLED = False

try:
    import RPi.GPIO as GPIO
    GPIO_ENABLED = True
except RuntimeError:
    # can only be run on RPi
    import RPi as GPIO


class MotorRunner:
    def __init__(self, spin_time=1.65, gpio_pin=18, frequency=50):
        self._GPIO_ENABLED = GPIO_ENABLED
        self.spin_time = spin_time
        self.gpio_pin = gpio_pin
        self.freq = frequency
        self.p = None

    def _init_gpio(self):
        GPIO.setwarnings(False)
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(self.gpio_pin, GPIO.OUT)
        self.p = GPIO.PWM(self.gpio_pin, self.freq)

    def spin_motor(self):
        try:
            if self._GPIO_ENABLED:
                self._init_gpio()
                GPIO.PWM(self.gpio_pin, self.freq)
                self.p.start(11)
                time.sleep(self.spin_time)
                self.p.stop()
        except Exception as e:
            sys.stderr.write("Error in running\n {}".format(e))

        finally:
            if self._GPIO_ENABLED:
                GPIO.cleanup()


def main():
    ex = MotorRunner()
    ex.spin_motor()


if __name__ == '__main__':
    main()

So that’s good. Not terribly large changes, and the changes are more focused on error handling of an import error than just a function of testing.

Test cases

Next we have our test cases. Unittest has a setUp and tearDown method that is called before each test method. This is where we’ll set up our Mock patching to override the GPIO and GPIO_ENABLED variables to fake our successful import and “call” the motor.

import unittest
import time

from hardwarelib import MotorRunner

from unittest import mock, TestCase
from unittest.mock import MagicMock


class TestExample(TestCase):
    def setUp(self):
        self.rpi_gpio_patcher = mock.patch('hardwarelib.GPIO')
        self.mocked_rpi = self.rpi_gpio_patcher.start()

        self.mocked_gpio_enabled_patcher = mock.patch('hardwarelib.GPIO_ENABLED', True)
        self.mocked_gpio_enabled = self.mocked_gpio_enabled_patcher.start()

    def tearDown(self):
        self.rpi_gpio_patcher.stop()
        self.mocked_gpio_enabled_patcher.stop()

As you can see here, the MotorRunner class is in the hardwarelib python file. What we’re actually patching is the hardwarelib.GPIO and hardwarelib.GPIO_ENABLED attributes. We’re patching those because the import of GPIO is where we get our error if it’s not on a Raspberry Pi system, and ensuring that our motor functions are actually called due to our GPIO_ENABLED dependent conditionals.

Once this is set, our first test method, making sure the class can be initialized, is pretty easy. We just test that an instance of the class can be created without error.

    def test_hardware_initialized(self):
        """
        Assert object created
        """
        test_example = MotorRunner()
        self.assertIsInstance(test_example, MotorRunner)

Next we use a feature of mock patching. We create an instance of the class, and call the spin_motor function. We can then make sure that the PWM method (of the RPi.GPIO that actually spins the motor) is called.

    def test_hardware_called(self):
        """
        Ensure PWM called
        """
        test_hardware = MotorRunner()
        test_hardware.spin_motor()
        self.assertTrue(self.mocked_rpi.PWM.called)

Assuming those succeed, we’re all good. But we might as well make sure that when we specify parameters, they actually are used as we expected. This gets into using mock patcher’s assert_called_with which verifies a method was called, and that specific parameters were used.

    def test_hardware_parameters_used(self):
        """
        Ensure PWM called with parameters
        """
        spin = 1
        freq = 25
        gpio_pin = 15
        test_hardware = MotorRunner(spin_time=spin, gpio_pin=gpio_pin, frequency=freq)
        pre_time = time.time()
        test_hardware.spin_motor()
        end_time = time.time()
        run_time = end_time - pre_time
        self.assertEqual("{:1.1f}".format(run_time), str(float(spin)))
        self.mocked_rpi.PWM.assert_called_with(gpio_pin, freq)

Because we’re using time to determine how long to run our motor, the statement self.assertEqual("{:1.1f}".format(run_time), str(float(spin))) calculates how long the spin_motor function took to return. We then convert that to a float with once decimal place, and compare it to how long we wanted it to spin. This is pretty simplistic and would fail without modification if we set spin to two decimal places, but this example is testing that our parameters are used successfully, and not testing parameters more deeply.

Running the tests

For small tests like this, I typically just call the python unittest function rather than using a larger test runner. A larger test or library could very well incorporate flake8 for linting, and tox for testing multiple python versions, and possibly a larger test runner such as nose. We can also call unittest.main() to handle this for us in our test class.

if __name__ == '__main__':
    unittest.main()

Altogether, our test file looks like:

import unittest
import time

from hardwarelib import MotorRunner

from unittest import mock, TestCase
from unittest.mock import MagicMock


class TestExample(TestCase):
    def setUp(self):
        self.rpi_gpio_patcher = mock.patch('hardwarelib.GPIO')
        self.mocked_rpi = self.rpi_gpio_patcher.start()

        self.mocked_gpio_enabled_patcher = mock.patch('hardwarelib.GPIO_ENABLED', True)
        self.mocked_gpio_enabled = self.mocked_gpio_enabled_patcher.start()

    def tearDown(self):
        self.rpi_gpio_patcher.stop()
        self.mocked_gpio_enabled_patcher.stop()

    def test_hardware_initialized(self):
        """
        Assert object created
        """
        test_example = MotorRunner()
        self.assertIsInstance(test_example, MotorRunner)

    def test_hardware_called(self):
        """
        Ensure PWM called
        """
        test_hardware = MotorRunner()
        test_hardware.spin_motor()
        self.assertTrue(self.mocked_rpi.PWM.called)

    def test_hardware_parameters_used(self):
        """
        Ensure PWM called with parameters
        """
        spin = 1
        freq = 25
        gpio_pin = 15
        test_hardware = MotorRunner(spin_time=spin, gpio_pin=gpio_pin, frequency=freq)
        pre_time = time.time()
        test_hardware.spin_motor()
        end_time = time.time()
        run_time = end_time - pre_time
        self.assertEqual("{:1.1f}".format(run_time), str(float(spin)))
        self.mocked_rpi.PWM.assert_called_with(gpio_pin, freq)


if __name__ == '__main__':
    unittest.main()

Requirements

A quick note, we just need a couple of requirements installed via pip to be able to run these tests:

rpi.GPIO
mock

Now we can run the tests through Unittest, or by calling the file directly. Default output is dots if tests are successful, and F if failed. I’ve got plenty of screen real estate, so I almost always tack on some number of vs.

Calling the unittest module:

$ python -m unittest -vv test_example.py 
test_hardware_called (test_example.TestExample) ... ok
test_hardware_initialized (test_example.TestExample) ... ok
test_hardware_parameters_used (test_example.TestExample) ... ok

----------------------------------------------------------------------
Ran 3 tests in 2.694s

OK

Calling the file directly.

$ python test_example.py  -vv
test_hardware_called (__main__.TestExample) ... ok
test_hardware_initialized (__main__.TestExample) ... ok
test_hardware_parameters_used (__main__.TestExample) ... ok

----------------------------------------------------------------------
Ran 3 tests in 2.690s

OK

Easy! Now we can continue building on our local environment with confidence that our hardware will do what we expect (assuming we wired it correctly)!