Writing tests for Micropython on the BBC micro:bit
Writing tests for Micropython on the BBC micro:bit
Summary
We can write tests for the Micropython used on the micro:bit using regular Python with the pytest and the pytest-mock modules.
The problem
The Micropython implementation for micro:bit has no built-in way to write unit tests. The Micropython used on the micro:bit is a separate implementation of Micropython than the general version which does have a module for unit testing.
I couldn’t find any examples online on how to write these tests. So I figured out a method and am writing it up here to help anybody else who is struggling with the same problem.
A solution
We can use test our Micropython code in regular Python using the pytest module. We mock the microbit specific methods using the pytest-mock module.
Example
The example presented here is taken from my handshake project. All of the code can be found in the project’s GitHub repository here. I wrote tests for the script called receive.py. This script goes onto a micro:bit called the receiver that receives signals by radio from another micro:bit which is called the transmitter. The receiver micro:bit displays a pattern when it gets a message containing ‘shake’. Full details of how the system works can be found here.
I put a copy of the listings discussed in this post at the end of this article. The GitHub site will have the most up to date code though.
Installing pytest
I use two modules that are not in the standard library. These are installed using the commands:
pip3 install pytest --user
pip3 install pytest-mock --user
The –user flag installs the modules for the current user only, to avoid adding modules to the system distribution.
pytest is the testing framework that I use. pytest-mock extends this by adding a wrapper around the mock library.
Mocking the microbit module
The first line in receive.py is:
from microbit import *
This microbit module does not exist in regular Python. It is a module that contains objects to represent the various pieces of hardware on the micro:bit board, such as the display.
We need to mock this module.
I wrote a mock for the microbit module called mock_microbit.py. This file mocks all of the objects that receive.py uses from the microbit module. These mocked objects are used during testing.
mock_microbit.py
from unittest.mock import Mock
button_a = Mock()
button_b = Mock()
display = Mock()
pause = Mock()
radio = Mock()
sleep = Mock()
Image = Mock()
How do we get our test to import the mocked microbit module? This is done in the following lines in test_receive.py:
import sys
sys.modules['microbit'] = __import__('mock_microbit')
This replaces any reference to the microbit module with our mocked module. So, when we call e.g. microbit.display, we get a Mock() object to play with. Otherwise, the script will crash with a ‘module not found’ error as there is no microbit module in regular Python.
importing mock functions
I import the Mock and patch methods so that I don’t have to write out unittest.mock.Mock and unittest.mock.patch each time I want to use Mock or patch.
I import all of the functions from receive.py so that I can test these functions.
This is done in the lines:
from unittest.mock import Mock
from receive import *
from unittest.mock import patch
setting up mocks that are used repeatedly
Some micro:bit functions are called in more than one place in receive.py. I created a function called setup that creates mocks for these so that I can call this function rather than repeatedly setting up the mocks. DRY. Do Not Repeat Yourself. Code the creation of these mocks once and call wherever needed.
def setup(mocker):
''' Setup global mocks and patches.'''
global mock_show, mock_send, mock_pause, mock_sleep
mock_show = mocker.Mock(name='show')
mocker.patch('receive.display.show', new=mock_show)
mock_send = mocker.Mock(name='send')
mocker.patch('receive.radio.send', new=mock_send)
mock_pause = mocker.Mock(name='pause')
mocker.patch('receive.pause', new=mock_pause)
mock_sleep = mocker.Mock(name='sleep')
mocker.patch('receive.sleep', new=mock_sleep)
Let’s look at the first mocked object in the lines:
mock_show = mocker.Mock(name='show')
mocker.patch('receive.display.show', new=mock_show)
I create a mock object called mock_show. I will use this to replace the object receive.display.show. Why? Python does not have a receive.display.show object - this is a part of the microbit module. So I need to replace each reference to this object with a mocked object.
I replace the ‘receive.display.show’ call with my mocked object in the line:
mocker.patch('receive.display.show', new=mock_show)
Now, each time that receive.display.show is called, the testing system replaces receive.display.show with the mock object mock_show instead of returning an error saying that receive.display.show does not exist.
Similarly, the other mock objects I set up in this function: mock_send, mock_pause and mock_sleep are used in the same way, to replace calls to the non-existant microbit functions with mocked objects.
testing the decrease_sensitivity function
To test the function decrease_sensitivity, which is shown below:
def decrease_sensitivity():
""" send message to decrease sensitivity """
display.show("-")
radio.send("decrease")
pause()
I wrote the following test function in test_receive.py:
def test_decrease_sensitivity(mocker):
# arrange
# mocked dependencies
setup(mocker)
# act
decrease_sensitivity()
# assert
mock_show.assert_called_with('-')
mock_send.assert_called()
mock_send.assert_called_with('decrease')
mock_pause.assert_called()
The basic layout of a test is: arrange, act, assert. I arrange the mocked upjects needed in this test by calling the function setup, passing the fixture mocker as an argument. The mocker fixture is a wrapper around the mock package, allowing us to use the functions in this package.
The act stage of the test is to call the function that I am testing, decrease_sensitivity().
We then assert the actions that should have happened. We check that the line:
display.show("-")
was called with the assert statment:
mock_show.assert_called_with('-')
mock_show is the mock object that replaces the call to display.show.
We check the line:
radio.send("decrease")
was called correctly with the assert statments:
mock_send.assert_called()
mock_send.assert_called_with('decrease')
The first checks that radio.send was called at all, the second verifies that it was called with “decrease”.
The last test is to check that the pause() function was called. This is done in the assert test:
mock_pause.assert_called()
testing the rest
The remaining tests are mostly similar to the detailed walk through of test_decrease_sensitivity given above. Some of the tests required mocking a received value. For instance, in the update_status function we have the lines:
incoming = radio.receive()
sleep(10)
if incoming == "shake":
shake_detected()
This is tested in the test function test_update_status_incoming. I created a mock object called receive.radio.receive. The first ‘receive’ is the name of the module we are testing - receive.py. I create a mock object for the radio.receive() object in this module, so the full mocked object name is receive.radio.receive.
I want to mock a return value when this mocked object is called. The return value is ‘shake’ so that shake_detected() is triggered. The mocks are set up in the lines:
mock_receive = mocker.Mock(name='receive')
mocker.patch('receive.radio.receive', new=mock_receive)
mock_receive.return_value = 'shake'
I check that I get the expected response - that shake_detected() is called - in the assert test:
mock_shake_detected.assert_called()
Why write tests
Nobody trusts code that you can’t demonstrate does what you say it will. Writing tests shows you understand the process of createing software through software engineering.
The main reasons I didn’t write tests until recently were Ignorance and Arrogance. Ignorance - not knowing how to do something is a prime reason for making excuses not to do it. Software testing just isn’t taught all that much. Ignorance of realising how much it speeds up my completing a project. Testing makes me write code in small, testable pieces. Testing small bits as I go along is more efficient than trying to debug a monolithic pile of spaghetti at the end.
Arrogance. My code is so clear and well written it obviously doesn’t need testing. It does. If I don’t practice writing tests on the simple stuff, I won’t learn how to test the complex stuff.
Writing tests is essential to demonstrate to yourself and others that your code is fit for purpose. They really help when I refactor my code, so that I can check my changes haven’t broken what the code is supposed to do. For example, if I find a faster way to write a maths function. Often faster means less clear! Having working tests for the slower but clearer maths function means I can check that my refactored function still does what I think it will do with the obfuscated faster function.
The code I write now may or may not have an impact if it fails, but if I don’t learn how to write tests on the simple stuff, I’ll never be able to do it on the hard stuff. I recently worked on a ship that hit a wind farm tower, cracking the hull below the water line. This was due to software failure. So now I’m a bit of a stuck record on testing software adequately.
I worked for a company that builds quick release anchors for oil rigs. Each component that went into the product had a certificate of testing. I even witnessed the metal that we paid to be forged to create one of the larger components being tested. The same outlook exists in software engineering. Each component needs to be shown to be able to do the function it was designed to carry out.
Acknowledgments
Stackoverflow, specifically the answer I got to this question got me started. I had an additional pointer from Stackoverflow called ukBaz who is the resident micro:bit expert.
This GitHub repository is a project aimed at automating writing tests. Not all of the tests that it generated were what I was after, but it got me far enough that I could figure out how to complete the tests.
https://chat.openai.com/chat. I found that if I repeated the same question enough times I got something helpful out of the system. Some of the answers were incomplete or just plain wrong though.
Listings
receive.py
""" receive shake and write to serial port
update shake threshold to shake_detector """
from microbit import *
radio.config(address=0x101000, group=40, channel=2, data_rate=radio.RATE_1MBIT)
print("shake receive started")
display.show(Image.DIAMOND)
radio.on()
def decrease_sensitivity():
""" send message to decrease sensitivity """
display.show("-")
radio.send("decrease")
pause()
def increase_sensitivity():
""" send message to increase sensitivity """
display.show("+")
radio.send("increase")
pause()
def pause():
""" pause and clear display """
sleep(100)
display.show(Image.DIAMOND)
def shake_detected():
print("shake")
display.show(Image.CHESSBOARD)
pause()
def update_status():
''' Update micro:bit status in main superloop. '''
if button_a.was_pressed():
decrease_sensitivity()
if button_b.was_pressed():
increase_sensitivity()
incoming = radio.receive()
sleep(10)
if incoming == "shake":
shake_detected()
def main():
while True:
# helper function to enable testing
update_status()
if __name__ == '__main__':
main()
test_receive.py
''' Tests for receive.py.
These tests run under Python, not micropython, using pytest.
microbit module functions are mocked in mock_microbit.py.
Matt Oppenheim Dec 2022.
'''
import sys
sys.modules['microbit'] = __import__('mock_microbit')
from unittest.mock import Mock, Mock
from receive import *
from unittest.mock import patch
def setup(mocker):
''' Setup global mocks and patches.'''
global mock_show, mock_send, mock_pause, mock_sleep
mock_show = mocker.Mock(name='show')
mocker.patch('receive.display.show', new=mock_show)
mock_send = mocker.Mock(name='send')
mocker.patch('receive.radio.send', new=mock_send)
mock_pause = mocker.Mock(name='pause')
mocker.patch('receive.pause', new=mock_pause)
mock_sleep = mocker.Mock(name='sleep')
mocker.patch('receive.sleep', new=mock_sleep)
def test_decrease_sensitivity(mocker):
# arrange
# mocked dependencies
setup(mocker)
# act
decrease_sensitivity()
# assert
mock_show.assert_called_with('-')
mock_send.assert_called()
mock_send.assert_called_with('decrease')
mock_pause.assert_called()
def test_increase_sensitivity(mocker):
setup(mocker)
increase_sensitivity()
mock_show.assert_called_with('+')
mock_send.assert_called()
mock_send.assert_called_with('increase')
mock_pause.assert_called()
def test_pause(mocker):
setup(mocker)
pause()
mock_sleep.assert_called_with(100)
mock_show.assert_called_with(Image.DIAMOND)
def test_shake_detected(mocker, capfd):
setup(mocker)
shake_detected()
out, err = capfd.readouterr()
assert out == 'shake\n'
mock_show.assert_called_with(Image.CHESSBOARD)
mock_pause.assert_called()
def test_update_status_a(mocker):
''' Test button a. '''
# mocked dependencies
mocker.patch.object(button_a, 'was_pressed')
button_a.was_pressed.return_value = True
mock_decrease_sensitivity = mocker.Mock(name='decrease_sensitivity')
mocker.patch('receive.decrease_sensitivity', new=mock_decrease_sensitivity)
mock_increase_sensitivity = mocker.Mock(name='increase_sensitivity')
#mocker.patch('receive.increase_sensitivity', new=mock_increase_sensitivity)
update_status()
mock_decrease_sensitivity.assert_called()
mock_increase_sensitivity.assert_not_called()
def test_update_status_b(mocker):
''' Test button_b. '''
mocker.patch.object(button_b, 'was_pressed')
button_b.was_pressed.return_value = True
mock_increase_sensitivity = Mock(name='increase_sensitivity')
mocker.patch('receive.increase_sensitivity', new=mock_increase_sensitivity)
mock_decrease_sensitivity = mocker.Mock(name='decrease_sensitivity')
update_status()
mock_decrease_sensitivity.assert_not_called()
mock_increase_sensitivity.assert_called()
def test_update_status_shake(mocker):
''' Test incoming = 'shake'. '''
mock_receive = mocker.Mock(name='receive')
mocker.patch('receive.radio.receive', new=mock_receive)
mock_receive.return_value = 'shake'
mock_shake_detected = mocker.Mock(name='shake_detected')
mocker.patch('receive.shake_detected', new=mock_shake_detected)
update_status()
mock_shake_detected.assert_called()
def test_update_status_incoming(mocker):
''' Test radio.receive() called. '''
mock_receive = mocker.Mock(name='receive')
mocker.patch('receive.radio.receive', new=mock_receive)
update_status()
mock_receive.assert_called()
def test_update_status_sleep(mocker):
setup(mocker)
update_status()
mock_sleep.assert_called_with(10)
I’ve just started using Cactus Commments: