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 "
", line 1, in File "/home/dan/Projects/mockhardware/.venv/lib64/python3.6/site-packages/RPi/GPIO/init.py", line 23, in 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 callingspin_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 v
s.
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)!