More often than not, an embedded project is more than just the blinky sketch, containing many unique approaches, ideas and structures. With a compiled language such as C/C++, you can easily make mistakes in a two-fold way: compile-time and run-time errors. The former are usually easy to spot; shortly after building your compiler terminal is red with "undefined reference to this" and "implicit cast to that". Much more sneaky are run-time errors. These occur after your program starts or a certain action, such as a button press, is performed. And since you work on an embedded system, these errors won't be caught and your system may reboot, losing important data or worse. Luckily, we can catch these errors before they cause damage, with Unit Testing.
Codebase full of errors?
I've found myself jotting down an idea for a program in the train or in some spare 15 minutes after lunch without the embedded system attached, and later mindlessly integrating that part of the program into a project. Or even worse: coding for hours without uploading to the MCU once. Be smarter than my past self and make use of Unit Testing.
Unit Testing follows the philosophy that each part of the code that computes something, should be probed for its correct computation in an isolated context. While a mathematician will debate you on whether you have to prove that 1+1 is 2, a Unit Test does not have to probe all computations, but most of them. How does this look in practice? Consider a piece of code that handles a transaction with an imaginary temperature sensor, communicating via the UART protocol:
int32_t get_temp(){
char cmd[] = "GET"; // "get" command as per data sheet
char buf[8]; // receive buffer
uart_transmit(cmd, sizeof(cmd)); // send command
uart_receive(buf, sizeof(buf)); // receive answer
return atoi(buf); // convert string to integer
}
A lot of things can go wrong here. Let's start with the first one:
uart_transmit(char* msg, uint16_t len)
This might be a system function or a wrapper you wrote for your MCU's UART peripheral. It takes a char* with a command and a size in bytes. Let's assume that this function returns something else than void, a success message for example, and you were simply too lazy to check it (I know that I have been too lazy in the past). This is the first issue. This function might return an error code in case that the peripheral is not initialized, blocked or not reachable for whatever reason. While you should always check return values in production code as well, we can also include that in a unit test:
void test_uart_transmit(){
char dummy[] = "dummy";
int8_t stat = uart_transmit(dummy, sizeof(dummy));
TEST_ASSERT_EQUAL(0, stat);
};
This simple test checks if transmitting a test string works, nothing else. We've essentially removed the first function call of get_temp() and tested it in an isolated context. We can also add more context tests such as sending an empty or ridiculously long string to check how robust the system function uart_transmit() is.
uart_receive(char* msg, uint16_t len)
In this function, we read data that has been sent back from the sensor. How such a retrieval works depends on the platform, but for simplicity let us assume that this function returns the string in the UART receive register and overwrites it after retrieval, meaning it consumed the value. If nothing is in that buffer, the function returns without modifying the return buffer. Again, we should assert that the return value itself represents that our system ended this function call normally.
void test_uart_receive(){
char buf[RESPONSE_LEN]; // defined in data sheet
memset(buf, 0, RESPONSE_LEN);
int8_t stat = uart_receive(buf, sizeof(buf));
TEST_ASSERT_EQUAL(0, stat);
TEST_ASSERT_EQUAL(RESPONSE_LEN-1, strlen(buf));
};
Again, we check for the successful transaction and also assert that the response has the expected length. In this example, the sensor always sends back 8 character bytes including the terminating '\0', which is why we check for a valid string length of RESPONSE_LEN-1. Now we can get into some more interesting tests.
Checking for plausibility of sensor results
In Python Unit Testing, you would define precomputed constants or fixtures to assert an algorithm's correct return value. Here, we are dealing with a live system that returns a value proportional to the state of thermodynamics around the sensor at this exact moment in time, or simpler, how hot it is. If the sensor is next to you in your cozy dev-cave, you might expect something between 19 and 24 °C, but certainly not 0 or 50 °C (I hope so). But later in deployment, you might actually measure such temperatures. You might even hit the full range of the sensor. So let's also check for range to determine the correctness of a result. We do not test atoi(), since it is a standard function that is guaranteed to work. In this example, the sensor is in your room and has to fulfill two conditions:
void test_get_temp(){
int32_t t = get_temp();
t /= 1000; // Sensor returns m°C
// signature is (delta, expected, actual)
TEST_ASSERT_INT32_WITHIN(50, 0, t);
#ifdef DEV_TEST
TEST_ASSERT_INT32_WITHIN(5, 20, t);
#endif
}
- Results have to be in the range that the datasheet specified. If it isn't, something went wrong during transmission or data parsing.
- At the time of unit testing, with your sensor next to you in a room, you should expect room temperature. Anything else might be a wrong offset or conversion.
Unity on PlatformIO
Did you notice how I used macros like TEST_ASSERT_EQUAL() for the explicit test call instead of concrete functions? I did not come up with this myself; this is the syntax of unity, a testing framework for C applications with a lot of flexibility and small footprint. For Arduino framework projects, it comes shipped with PlatformIO and is plug-and-play ready. If you need some help to get started with PlatformIO in general, see my beginner's guide:
Create a file called test_temp_sensor.cpp under the folder test or optionally categorized under test/test_temp_sensor<platform> where you place your test code, in my case under test/test_temp_sensor_esp32 and test/test_temp_sensor_stm32, because I want to test both platforms.
Unity expects starter function definitions, which means a standard int main() or the classic void setup() and void loop() of the Arduino framework. But let's make it concrete in a full copy-paste-able code snippet.
A basic Unit Test
// test_temp_sensor_esp32.cpp
#include <Arduino.h>
#include <unity.h>
#include "ex_uart.h"
#include "temp_sensor.h"
void setUp(){
// this is not the Arduino `setup()`!
// this function gets executed before every test
// populating this is optional
}
void tearDown(){
// this function gets executed after every test
// populating this is optional
}
void test_uart_init(){
int8_t stat = uart_init(115200);
TEST_ASSERT_EQUAL(0, stat);
}
void test_uart_transmit(){
char dummy[] = "dummy";
int8_t stat = uart_transmit(dummy, sizeof(dummy));
TEST_ASSERT_EQUAL(0, stat);
};
void test_uart_receive(){
char buf[RESPONSE_LEN];
memset(buf, 0, RESPONSE_LEN);
int8_t stat = uart_receive(buf, sizeof(buf));
TEST_ASSERT_EQUAL(0, stat);
TEST_ASSERT_EQUAL(RESPONSE_LEN-1, strlen(buf));
};
void test_get_temp(){
int32_t t = get_temp();
t /= 1000; // Sensor returns m°C
// signature is (delta, expected, actual)
TEST_ASSERT_INT32_WITHIN(50, 0, t);
#ifdef DEV_TEST
TEST_ASSERT_INT32_WITHIN(5, 20, t);
#endif
}
void setup(){
delay(2000); // some hardware may need to settle first
UNITY_BEGIN(); // start a unit test session
RUN_TEST(test_uart_init);
RUN_TEST(test_uart_transmit);
RUN_TEST(test_uart_receive);
RUN_TEST(test_get_temp);
UNITY_END(); // end a unit test session
}
void loop(){
// stays empty
}
In this test, we define optional setUp() and tearDown() functions to execute code before and after each test. This is practical for establishing defined before and after states for variables that are outside this scope or global. They don't need to be used or even defined. Next, we add all test functions from before. These will be tested by passing their function pointers to unity's RUN_TEST() macro. This however needs to be pre- and postfixed with UNITY_BEGIN() and UNITY_END(). It is also good practice to let the controller settle with a short delay() before testing, as an immediate test may yield some undefined states from external hardware such as a temperature sensor or lead to the test messages being mangled with a boot-up message from the controller (such as the ESP32).
You can trigger a test by selecting the correct PlatformIO environment and pressing the little flask icon.
Alternatively, you can use the PlatformIO CLI with
pio test -e my_env
Some platforms may take their time when test code is flashed for the first time. If you can't see any results, be sure that your controller does not crash directly at boot and that it can properly communicate with your client PC. For example, if you use a board such as the ESP32-S3-DevKit1-c, you have access to both a UART-to-USB connection and a general USB connection. If you flash that controller directly on the USB line, it will still expect to print test results to the UART-to-USB connection, which you might have connected to a broken cable or not a all. A successful test should look like this:
Custom test structure
We now have all the tools to test all the code, but a full test coverage (a.k.a all functions are at least once tested in a unit test) will quickly increase the size of this file to 4 digit lines, at least in a bigger project. For this reason, it makes sense to split the tests into logical groups that are easier to manage in terms of maintenance and actual error catching.
Suppose you have a system with multiple peripherals and some signal processing. These peripherals may be connected to entirely different sensors or other integrated circuits that use different busses and timings. In order to bunch all of these up categorically you can introduce a custom test structure with folders and subfolders. In this example, we have an external SPI flash, an I²C ADC and algorithms implementing an FFT and other filtering. Let's split the tests like this:
test
├── test_dsp
│ ├── test_fft.cpp
│ ├── test_rms.cpp
│ ├── test_fft.h
│ └── test_rms.h
├── test_flash_io
│ ├── test_rw.cpp
│ └── test_rw.h
├── test_adc
│ ├── test_samples.cpp
│ └── test_samples.h
├── test_common.cpp
└── test_common.h
test_common.cpp will then be the file that calls all tests that are listed in the subfolders, if you include them there. Alternatively, you can omit test_common.cpp/h and have definitions for the program entry (meaning setup() or main()) in each one of the subfolders, which makes you able to pick them out for specific environments as such:
; platformio.ini
[env:my_env]
platform = espressif32
framework = arduino
board = esp32-c3-devkitm-1
upload_speed = 921600
monitor_speed = 115200
[env:io_testing]
extends = env:my_env ; inherit all properities from my_env
test_filter = test_flash_io
When you now select the environment env:io_testing and run a test, it will only run the tests in in the filtered folder test_flash_io. If you do not specify a test filter, the tests under the normal test folder will be run.
Custom transport
Now let's apply what we learned to this STM32 MCU with the STM32 HAL. Set up the board config and hit test. The output will be:
That didn't work out. Why? That is because there needs to be a mechanism in place that transports the results of the unit test to your PC, which we will call the client from here on. For the Arduino framework, this is already done "automagically". The test results are parsed to the devices communication peripheral, which is most often the UART peripheral. In short: a unit test needs to call Serial.print() or something similar to print the results for you to read. This "something similar" is exactly what the project is missing to display a unit test, because the STM32 HAL does not have access to Serial.print(). So let's add the STM32 HAL version of a serial communication, with HAL_UART_Transmit().
If you have worked with the STM32 HAL, you might know that it offers a lot more clock tree customization than the ESP32 with the Arduino FW, which means that you have to conat we learned to this STM32 MCfigure a ton more in the initialization routine. The STM32 HAL also gives you access to the actual start of the program, which is int main(void). Neither Arduino nor the ESP-IDF let you do that, as they both execute some startup code before they let you insert yours, which is in setup() or app_main() respectively. Here our challenge is to correctly set up UART transmission (not reception) for the STM32 platform and to let unity now how to access these with macros.
Initializing UART on the specific platform
In this specific example, we are lucky enough to actually test the UART peripheral itself, as our imagined temperature sensor works that way. This of course requires the UART driver code to work in a context that the unit test has to reside in. After all, you need to be able to speak before you can say the words "I can speak"; the existence of the statement is enough to verify the statement without requiring its semantic contents.
Are you still listening? Let's jump to the code.
UART_HandleTypeDef huart2;
int8_t uart_init(uint32_t baud){
if(baud > 115200){
return UART_ERR_PARAM;
}
// STM32 specific UART init code
huart2.Instance = USART2;
huart2.Init.BaudRate = baud;
huart2.Init.WordLength = UART_WORDLENGTH_8B;
huart2.Init.StopBits = UART_STOPBITS_1;
huart2.Init.Parity = UART_PARITY_NONE;
huart2.Init.Mode = UART_MODE_TX_RX;
huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart2.Init.OverSampling = UART_OVERSAMPLING_16;
if (HAL_UART_Init(&huart2) != HAL_OK) {
return UART_ERR_PARAM;
}
return UART_ERR_NONE;
}
int8_t uart_transmit(char* buf, uint8_t len){
if(buf == NULL){
return (int8_t)UART_ERR_BUF_NULL;
}
if(len > UART_MAX_DATA_LEN){
return (int8_t)UART_ERR_LEN_OVR;
}
HAL_UART_Transmit(&huart2, (uint8_t*)buf, len, HAL_MAX_DELAY)
return (int8_t)UART_ERR_NONE;
}
int8_t uart_receive(char* buf, uint8_t len){
if(buf == NULL){
return (int8_t)UART_ERR_BUF_NULL;
}
if(len > UART_MAX_DATA_LEN){
return (int8_t)UART_ERR_LEN_OVR;
}
// Actual receiving would happen here
strcpy(buf, "dummyXX");
return (int8_t)UART_ERR_NONE;
}
void HAL_UART_MspInit(UART_HandleTypeDef* huart) {
// STM32 specific UART init callback code
GPIO_InitTypeDef GPIO_InitStruct = {0};
if(huart->Instance == USART2) {
__HAL_RCC_USART2_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_2 | GPIO_PIN_3;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF7_USART2;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}
}
To enable the UART peripheral on an STM32 with the HAL, we need to set up the UART peripheral both in terms of configuration and hardware connections. But not just any UART, we need the one that is connected to your client PC, which is most likely an ST-LINK. You can see the type of connected device by accessing the Devices tab in PlatformIO. There it will show you your board. It requires some data sheet surfing to find out which UART is connected to the ST-LINK, but it is most often the USART2 connection. We need a configuration that describes the UART settings and a different configuration to configure the pins. Sadly, the STM HAL forces you to write pin configuration for a peripheral as override of a __weak function, which means that you have to use its name and signature. In this case, it has to be named void HAL_UART_MspInit(UART_HandleTypeDef* huart). Only then will the HAL draw a connection between your UART config in uart_init() and your pin config, because that calls HAL_UART_Init(&huart2), which then under the hood calls HAL_UART_MspInit().
Quick Note
Boards like the Bluepill do not have a UART-USB bridge or anything similar connected to their USB port. Their USB port can only transmit data by configuring the firmware to use the USB port with an actual USB communication method, such as USB-CDC, which is not the same as UART. However, this would go beyond this guide.
Passing UART functions to unity
Now that we can write data with HAL_UART_Transmit(), we can also configure unity to use that. For this we create a unity_config.h and unity_config.c.
// unity_config.h
#ifndef UNITY_CONFIG_H
#define UNITY_CONFIG_H
#ifndef NULL
#ifndef __cplusplus
#define NULL (void*)0
#else
#define NULL 0
#endif
#endif
#ifdef __cplusplus
extern "C"
{
#endif
void unityOutputStart();
void unityOutputChar(char);
void unityOutputFlush();
void unityOutputComplete();
#define UNITY_OUTPUT_START() unityOutputStart()
#define UNITY_OUTPUT_CHAR(c) unityOutputChar(c)
#define UNITY_OUTPUT_FLUSH() unityOutputFlush()
#define UNITY_OUTPUT_COMPLETE() unityOutputComplete()
#ifdef __cplusplus
}
#endif /* extern "C" */
#endif /* UNITY_CONFIG_H */
In unity_config.h we map the most basic UART functions to the unity macros. All we need is an init-function for the peripheral, a way to write a single character and optionally, a way to flush data and to finish the transmission.
// unity_config.c
#include "unity_config.h"
#include "stm32g4xx_hal.h"
#include "ex_uart.h"
void unityOutputStart(){
uart_init(115200);
}
void unityOutputChar(char c){
char s[] = {0, 0};
s[0] = c;
uart_transmit(s, 1);
}
void unityOutputFlush() {
}
void unityOutputComplete(){
}
In unity_config.c we write the actual implementation of these calls with the mentioned UART functions, which then call the STM32 HAL functions. In short, this is the call stack:
UNITY_OUTPUT_CHAR(c)->unityOutputChar(c)->uart_transmit(&c, 1)->HAL_UART_Transmit(&huart, &c, 1, HAL_MAX_Delay)
With this, unity can now perform write actions with the UART peripheral to print test results to the client PC. The PlatformIO build chain will automatically scan your selected test folder for a unity_config.h file if it exists and use its configured UART calls.
Limitations
With all good things come some caveats. Here's a few of them when using PlatformIO unit testing with unity:
- In general: Unit tests are only as good as you write them. Especially on embedded systems, your hardware may be in a "detached" state from your program counter, as is the case with timers, DMAs and caches. This puts up some additional challenges for your test code, as single tests might not actually be completely independent of each other. After all, your device needs to be in an initialized state before you can test certain features, meaning that the results of the your
test_init()function were not "cleaned up" in that sense. In similar spirit, most tests would likely fail if you were to "undo" whattest_init()performed. - Everything UI: You got your button, interrupt service routine and display, but who is going to press that button once you start unit testing? Or even worse, who is going to tell you if the screen shows the correct contents? While there are some very ambitious human-machine interaction and computer vision projects, setting this up is not going to be simple or fast. For your Maker projects, testing UI will probably be something that you have to do manually.
- Hard-faults: An embedded system will crash or reset ungracefully when encountering a hard-fault, such as a
NULLde-reference. A unit test will not save you from this, as the test code still runs on an embedded system. With unity, this can be seen by a few success messages, which then pause and repeat. This is because the MCU resets and executes the test code again, dying at the same line of code as before.
Now you learned how you can apply unit testing on an embedded device with PlatformIO, unity and two different platforms and frameworks. Building a project with unit tests from the start will save you a lot of trouble later on, as you can safely rely on the single routines you wrote. You can find the PlatformIO project of this demonstration in the accompanying repository. If you like this content and think it is useful, consider a donation with KoFi, as that helps me making this content.





