Updated (September 9, 2017): Updated this post use ceedling
commands instead of rake
commands based on recent changes to Ceedling. See this post for more details.
Get the source code for this example on GitHub.
How can you unit test your embedded software? What about your hardware dependencies?
The secret is mocking.
We can mock the interfaces to our hardware so that we don't need the actual hardware to test. This allows us to run our tests more quickly and before the hardware might even be available.
The Plan
If we're developing the software for an embedded microcontroller, we're probably going to be using the microcontroller-provided hardware modules for things like SPI, I2C, timers, etc.
For each of these hardware interfaces, we want to have a corresponding software module containing the microcontroller hardware dependencies (i.e. hardware register accesses).
We can then mock each of these hardware interfaces, eliminating our hardware dependencies but still allowing us to unit test our application. Instead of compiling these tests for the embedded microcontroller, we compile them for and run them on our host PC.
To help you create your mocks you want to use a mocking framework. The mocking framework included with Ceedling is CMock. It allows you to create mocks of individual software modules from their header files. Ceedling improves the experience by automatically using CMock to generating the mocks that we need.
A Test Driven Example
Note that this example assumes that we already have an existing Ceedling project. See my other article for help creating one.
Imagine that we want to talk to an external I2C temperature sensor.
Create the Temperature Sensor Module
Let's create a module that will be our temperature sensor driver.
$ ceedling module:create[tempSensor]
Generating 'tempSensor'...
mkdir -p ./test/.
mkdir -p ./src/.
File ./test/./test_tempSensor.c created
File ./src/./tempSensor.c created
File ./src/./tempSensor.h created
Write Our First Test
What is the first thing I want to be able to do with this sensor? I'd like to be able to read the current temperature value.
Cool. So I take a look at the datasheet for my fictional temperature sensor and I can see that it has a bunch of 16-bit registers -- each with 8-bit addresses -- one of which is the temperature register.
The scaling of the values is such that a register value of 0 is -100.0°C and a register value of 0x3FF is +104.6°C. This makes each bit equivalent to 0.2°C.
Now lets add our first test to test_tempSensor.c. I want to know that when I read a temperature register value of 0x3FF that the temperature calcualted is 104.6.
void test_whenTempRegisterReadsMaxValue_thenTheTempIsTheMaxValue(void)
{
uint8_t tempRegisterAddress = 0x03;
float expectedTemperature = 104.6f;
float tolerance = 0.1f;
//When
i2c_readRegister_ExpectAndReturn(tempRegisterAddress, 0x3ff);
//Then
float actualTemperature = tempSensor_getTemperature();
TEST_ASSERT_FLOAT_WITHIN(tolerance, expectedTemperature,
actualTemperature);
}
First we set up some variables to hold our expected values. Then in the "when" clause, we need to simulate (or mock) the I2C module returning a value of 0x3ff on a read of the temperature address.
For the moment, we pretend that there is another i2c module (it doesn't actually exist yet) which handles the I2C communication with the temperature sensor. This is where our hardware dependent code will eventually go.
So, the i2c_readReadgister_ExpectAndReturn
function is actually a mock function used to simulate a call to a function called i2c_readRegister
in the i2c module. We'll come back to this in a moment.
The "then" clause is where we test that the tempSensor module actually returns the correct temperature when we call tempSensor_getTemperature
. This function doesn't exist yet either.
Create the Function Under Test
Lets create the tempSensor_getTemperature
function with a dummy implementation:
tempSensor.h:
# ifndef tempSensor_H
# define tempSensor_H
float tempSensor_getTemperature(void);
# endif // tempSensor_H
tempSensor.c:
# include "tempSensor.h"
float tempSensor_getTemperature(void)
{
return 0.0f;
}
Mock the I2C Interface
If we try and run the test now, the compiler will complain that it doesn't know about the i2c_readReadgister_ExpectAndReturn
mock function. This is because the i2c_readRegister
function doesn't exist and we haven't yet told Ceedling to mock it.
We don't actually need to implement this function however. It's enough to declare the function prototype in a header file and tell Ceedling to mock it with CMock.
Create the header file, i2c.h:
# ifndef i2c_H
# define i2c_H
# include <stdint.h>
uint16_t i2c_readRegister(uint8_t registerAddress);
# endif // i2c_H
The way we tell Ceedling to mock this module is to add this line to test_tempSensor.c:
# include "mock_i2c.h"
This tells Ceedling: You know the i2c.h header you see over there? Well... use CMock to generate the implementation and compile it in for us, okay?
When CMock gets a hold of the header file it looks at all the functions defined there and generates several mock functions for each... including the i2c_readRegister_ExpectAndReturn
function we used in the test. This mock function appends an additional argument to the original i2c_readRegister
function, which is the value we want the function to return to the calling function.
For more details on all the mock functions available with CMock, see the CMock documentation.
Implement the Function Under Test
Now we can implement the logic for our tempSensor_getTemperature
function. Our new tempSensor.c is:
# include "tempSensor.h"
# include "i2c.h"
# include <stdint.h>
float tempSensor_getTemperature(void)
{
uint16_t rawValue = i2c_readRegister(0x03);
return -100.0f + (0.2f * (float)rawValue);
}
If we run our test, it should pass now.
Adding Another Test
We'll next want to add more tests for other possible return values from i2c_readRegister
. This is easily done by changing the return value provided to the mock function.
For example, to test that the minimum temperature value is read correctly:
void test_whenTempRegisterReadsMinValue_thenTheTempIsTheMinValue(void)
{
uint8_t tempRegisterAddress = 0x03;
float expectedTemperature = -100.0f;
float tolerance = 0.1f;
//When
i2c_readRegister_ExpectAndReturn(tempRegisterAddress, 0x0);
//Then
float actualTemperature = tempSensor_getTemperature();
TEST_ASSERT_FLOAT_WITHIN(tolerance, expectedTemperature,
actualTemperature);
}
Now we have a driver for an external hardware device that we can test without any of the hardware. We can continue to develop the driver -- adding more tests and features -- by building and testing on our host PC.
By putting all of the microcontroller-dependent I2C operations into their own module, we easily mocked them with Ceedling and CMock. In fact, we didn't even have to implement this module yet -- we just had to define its interface in the header file.
Using our mocks, we created unit tests that verify the behavior of our temperature sensor driver. As the rest of our application is developed, we can easily run these unit tests at any time to make sure the driver will still work correctly.
Save this for later
There's a ton of stuff in this article. If you want to save it to refer to later -- get everything in this here (and more) in my downloadable "how to" guide.Sign up to get it here.