Embedded Systems Unit Testing: Unity, CMock, and TDD for C/C++ Firmware
Embedded firmware has a reputation for being untestable. Hardware dependencies, bare-metal loops, global state, and memory constraints all make traditional testing approaches difficult. Many firmware teams rely entirely on hardware-in-the-loop testing — running code on real devices and checking behavior manually.
This approach is slow, doesn't scale, and misses an entire class of logic bugs that a good unit test would catch in seconds. Unity and CMock change this.
Why Embedded Firmware Is Hard to Unit Test
The root problem is hardware coupling. A function that reads a temperature sensor looks like this:
float read_temperature(void) {
while (SPI1->SR & SPI_SR_BSY); // wait for SPI bus
SPI1->DR = 0xAB; // send read command
while (!(SPI1->SR & SPI_SR_RXNE)); // wait for response
uint16_t raw = SPI1->DR;
return raw * 0.0625f; // convert to Celsius
}This function is impossible to unit test without real hardware — it directly accesses memory-mapped hardware registers (SPI1->SR, SPI1->DR). Any attempt to run it on a development machine will crash.
The solution is hardware abstraction: separate the hardware interaction from the logic that uses it.
Hardware Abstraction for Testability
The key insight: firmware functions that are hard to test are hard to test because they do two things at once — they interact with hardware AND they implement logic.
Separate these concerns:
// hal_spi.h — Hardware Abstraction Layer interface
typedef struct {
void (*init)(void);
uint16_t (*transfer)(uint8_t cmd);
bool (*is_busy)(void);
} SPI_HAL;
// sensor_temperature.h — Business logic
typedef struct {
SPI_HAL *spi;
} TempSensor;
float temperature_read(TempSensor *sensor) {
while (sensor->spi->is_busy());
uint16_t raw = sensor->spi->transfer(0xAB);
return raw * 0.0625f;
}Now temperature_read depends on an interface (SPI_HAL), not on hardware registers. In production, you inject a real SPI HAL. In tests, you inject a mock.
This is the core pattern that makes embedded unit testing possible.
Unity Test Framework
Unity is the most widely used unit testing framework for C. It's a single header + source file pair, compiles anywhere, and produces output that CI systems understand.
Setup
# Download Unity
git submodule add https://github.com/ThrowTheSwitch/Unity.git vendor/unity
<span class="hljs-comment"># Or CMake FetchContent
FetchContent_Declare(unity
GIT_REPOSITORY https://github.com/ThrowTheSwitch/Unity.git
GIT_TAG v2.5.2
)
FetchContent_MakeAvailable(unity)Writing Unity Tests
// test_temperature_sensor.c
#include "unity.h"
#include "sensor_temperature.h"
// Mock SPI for tests
static uint16_t mock_raw_value = 0;
static bool mock_busy = false;
static int transfer_call_count = 0;
static uint16_t mock_spi_transfer(uint8_t cmd) {
transfer_call_count++;
TEST_ASSERT_EQUAL_HEX8(0xAB, cmd); // verify correct command
return mock_raw_value;
}
static bool mock_spi_is_busy(void) {
return mock_busy;
}
static SPI_HAL test_spi = {
.transfer = mock_spi_transfer,
.is_busy = mock_spi_is_busy,
};
static TempSensor sensor = { .spi = &test_spi };
// Unity setup/teardown
void setUp(void) {
mock_raw_value = 0;
mock_busy = false;
transfer_call_count = 0;
}
void tearDown(void) {}
// Test cases
void test_temperature_read_normal_value(void) {
mock_raw_value = 400; // 25.0°C in raw ADC counts (400 * 0.0625 = 25.0)
float temp = temperature_read(&sensor);
TEST_ASSERT_FLOAT_WITHIN(0.01f, 25.0f, temp);
}
void test_temperature_read_zero_celsius(void) {
mock_raw_value = 0;
float temp = temperature_read(&sensor);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, temp);
}
void test_temperature_read_negative_value(void) {
// Two's complement for -2.5°C at 0.0625 resolution
mock_raw_value = 0xFFD8;
float temp = temperature_read(&sensor);
TEST_ASSERT_FLOAT_WITHIN(0.01f, -2.5f, temp);
}
void test_temperature_sends_correct_spi_command(void) {
mock_raw_value = 400;
temperature_read(&sensor);
TEST_ASSERT_EQUAL(1, transfer_call_count);
// Command value verified inside mock_spi_transfer
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_temperature_read_normal_value);
RUN_TEST(test_temperature_read_zero_celsius);
RUN_TEST(test_temperature_read_negative_value);
RUN_TEST(test_temperature_sends_correct_spi_command);
return UNITY_END();
}Unity Assertions Reference
// Integer assertions
TEST_ASSERT_EQUAL(expected, actual);
TEST_ASSERT_EQUAL_INT(expected, actual);
TEST_ASSERT_EQUAL_UINT32(expected, actual);
TEST_ASSERT_EQUAL_HEX8(expected, actual); // hex display
TEST_ASSERT_EQUAL_HEX16(expected, actual);
// Float assertions
TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual);
TEST_ASSERT_EQUAL_FLOAT(expected, actual);
// Boolean assertions
TEST_ASSERT_TRUE(condition);
TEST_ASSERT_FALSE(condition);
TEST_ASSERT_NULL(pointer);
TEST_ASSERT_NOT_NULL(pointer);
// Array assertions
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, actual, num_elements);
TEST_ASSERT_EQUAL_MEMORY(expected, actual, len);
// String assertions
TEST_ASSERT_EQUAL_STRING(expected, actual);
TEST_ASSERT_EQUAL_STRING_LEN(expected, actual, len);CMock: Automatic Mock Generation
Manually writing mocks like the SPI example above works, but it's tedious and error-prone for complex interfaces. CMock generates mocks automatically from C header files.
Setup
# CMock is a companion to Unity
git submodule add https://github.com/ThrowTheSwitch/CMock.git vendor/cmock
gem install cmock <span class="hljs-comment"># CMock's code generator is written in RubyGenerate Mocks from Headers
# Generate mock for hal_spi.h
ruby vendor/cmock/lib/cmock.rb hal_spi.h
<span class="hljs-comment"># Output: MockHAL_SPI.h and MockHAL_SPI.cUsing Generated Mocks
// test_temperature_with_cmock.c
#include "unity.h"
#include "cmock.h"
#include "MockHAL_SPI.h" // generated by CMock
#include "sensor_temperature.h"
void setUp(void) {
CMock_Init();
MockHAL_SPI_Init();
}
void tearDown(void) {
MockHAL_SPI_Verify();
MockHAL_SPI_Destroy();
CMock_Teardown();
}
void test_reads_temperature_from_sensor(void) {
// Expect is_busy called once, returns false
spi_is_busy_ExpectAndReturn(false);
// Expect transfer called with 0xAB, returns 400
spi_transfer_ExpectAndReturn(0xAB, 400);
// Inject mock HAL
SPI_HAL mock_hal = {
.is_busy = spi_is_busy,
.transfer = spi_transfer,
};
TempSensor sensor = { .spi = &mock_hal };
float temp = temperature_read(&sensor);
TEST_ASSERT_FLOAT_WITHIN(0.01f, 25.0f, temp);
// CMock automatically verifies all expectations in tearDown
}
void test_waits_for_spi_bus_ready(void) {
// First call: busy. Second call: not busy.
spi_is_busy_ExpectAndReturn(true);
spi_is_busy_ExpectAndReturn(false);
spi_transfer_ExpectAndReturn(0xAB, 200);
SPI_HAL mock_hal = { .is_busy = spi_is_busy, .transfer = spi_transfer };
TempSensor sensor = { .spi = &mock_hal };
temperature_read(&sensor);
// CMock verifies is_busy was called exactly twice
}CMock enforces call order, argument values, and call counts. If spi_transfer is called with the wrong command byte, the test fails with a descriptive error message.
TDD Workflow for Embedded Firmware
Test-Driven Development on embedded firmware follows the same red-green-refactor cycle as application software, with one addition: the HAL design is driven by the tests.
Step 1: Write the Test First
You need a function that validates a CRC-16 checksum on received UART data. Start with the test:
// test_uart_receiver.c
void test_accepts_valid_crc(void) {
uint8_t data[] = { 0x01, 0x02, 0x03 };
uint16_t valid_crc = crc16_calculate(data, 3);
bool result = uart_receive_validate(data, 3, valid_crc);
TEST_ASSERT_TRUE(result);
}
void test_rejects_corrupted_data(void) {
uint8_t data[] = { 0x01, 0x02, 0x03 };
uint16_t wrong_crc = 0xFFFF;
bool result = uart_receive_validate(data, 3, wrong_crc);
TEST_ASSERT_FALSE(result);
}These tests don't compile yet — uart_receive_validate and crc16_calculate don't exist.
Step 2: Write Minimum Code to Compile
// uart_receiver.h
bool uart_receive_validate(uint8_t *data, size_t len, uint16_t crc);
// crc16.h
uint16_t crc16_calculate(uint8_t *data, size_t len);Step 3: Make Tests Pass
// crc16.c
uint16_t crc16_calculate(uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for (int j = 0; j < 8; j++) {
if (crc & 0x8000) {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
return crc;
}
// uart_receiver.c
bool uart_receive_validate(uint8_t *data, size_t len, uint16_t expected_crc) {
return crc16_calculate(data, len) == expected_crc;
}Step 4: Refactor and Add Edge Cases
void test_empty_data_returns_initial_crc(void) {
uint16_t crc = crc16_calculate(NULL, 0);
TEST_ASSERT_EQUAL_UINT16(0xFFFF, crc);
}
void test_single_byte(void) {
uint8_t data = 0x31;
uint16_t crc = crc16_calculate(&data, 1);
TEST_ASSERT_EQUAL_UINT16(0xE5CC, crc); // known value for byte 0x31
}State Machine Testing
State machines are everywhere in embedded firmware. Unity makes them testable:
// Device connection state machine
typedef enum {
STATE_IDLE,
STATE_CONNECTING,
STATE_CONNECTED,
STATE_ERROR,
} ConnectionState;
void test_transitions_to_connecting_on_connect_event(void) {
ConnectionStateMachine sm;
sm_init(&sm);
TEST_ASSERT_EQUAL(STATE_IDLE, sm.state);
sm_event(&sm, EVENT_CONNECT);
TEST_ASSERT_EQUAL(STATE_CONNECTING, sm.state);
}
void test_transitions_to_error_on_timeout_in_connecting(void) {
ConnectionStateMachine sm;
sm_init(&sm);
sm_event(&sm, EVENT_CONNECT); // enter CONNECTING state
sm_event(&sm, EVENT_TIMEOUT);
TEST_ASSERT_EQUAL(STATE_ERROR, sm.state);
}
void test_ignores_connect_event_when_already_connected(void) {
ConnectionStateMachine sm;
sm_init(&sm);
sm_event(&sm, EVENT_CONNECT);
sm_event(&sm, EVENT_CONNECTED); // enter CONNECTED
sm_event(&sm, EVENT_CONNECT); // should be ignored
TEST_ASSERT_EQUAL(STATE_CONNECTED, sm.state);
}CMake Integration
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(firmware-tests C)
# Unity library
add_library(unity STATIC
vendor/unity/src/unity.c
)
target_include_directories(unity PUBLIC vendor/unity/src)
# CMock library
add_library(cmock STATIC
vendor/cmock/src/cmock.c
)
target_include_directories(cmock PUBLIC vendor/cmock/src)
target_link_libraries(cmock unity)
# Generate mocks
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/MockHAL_SPI.c
${CMAKE_CURRENT_BINARY_DIR}/MockHAL_SPI.h
COMMAND ruby ${CMAKE_SOURCE_DIR}/vendor/cmock/lib/cmock.rb
--mock_path=${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_SOURCE_DIR}/src/hal_spi.h
DEPENDS src/hal_spi.h
)
# Test executable
add_executable(test_temperature
tests/test_temperature_sensor.c
src/sensor_temperature.c
${CMAKE_CURRENT_BINARY_DIR}/MockHAL_SPI.c
)
target_include_directories(test_temperature PRIVATE
src/
${CMAKE_CURRENT_BINARY_DIR}
)
target_link_libraries(test_temperature unity cmock)
# CTest integration
enable_testing()
add_test(NAME temperature_tests COMMAND test_temperature)CI Pipeline for Embedded Unit Tests
name: Firmware Unit Tests
on: [pull_request]
jobs:
firmware-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install dependencies
run: |
sudo apt-get install -y cmake gcc ruby
gem install cmock
- name: Configure and build
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Debug -DTESTING=ON
cmake --build build
- name: Run tests
run: ctest --test-dir build --output-on-failure -V
- name: Static analysis
run: |
cppcheck --enable=all --std=c11 \
--error-exitcode=1 \
--suppress=missingIncludeSystem \
src/Common Embedded Testing Mistakes
Testing hardware register access directly. If a test requires real hardware registers, it's an integration test, not a unit test. Factor out the hardware access behind an interface.
Global state without reset. Embedded code often uses global state (hardware registers, buffers). Tests must reset all global state in setUp() — state leaking between tests causes flaky, order-dependent failures.
Not testing state machine guard conditions. Testing only happy-path state transitions misses the bugs that occur when an event arrives in an unexpected state.
Ignoring integer overflow. Sensor value calculations on constrained hardware often use fixed-point arithmetic. Test boundary values and verify overflow behavior explicitly.
No CI integration. Unit tests that only run locally are tests that stop running. Connect them to CI from day one.
HelpMeTest complements embedded unit testing by covering the application layer — the cloud APIs, dashboards, and alerts built on top of your firmware. Start free.