3 Unit Testing for IoT Firmware
3.1 Learning Objectives
By the end of this chapter, you will be able to:
- Write Effective Unit Tests: Create unit tests for firmware logic using frameworks like Unity
- Mock Hardware Dependencies: Abstract and mock sensors, timers, and GPIO for testing
- Set Coverage Targets: Define appropriate coverage levels for different code criticality
- Implement Test-Driven Development: Apply TDD principles to embedded development
For Beginners: Unit Testing for IoT Firmware
Unit testing checks that individual components of your IoT system work correctly in isolation. Think of testing each ingredient in a recipe before combining them – if the flour is bad, you want to know before baking the whole cake. In IoT, unit tests verify that each sensor driver, algorithm, and communication module works as expected.
Sensor Squad: Testing One Piece at a Time
“Unit testing is like checking each ingredient before baking a cake!” said Max the Microcontroller. “You test the temperature conversion function: does Celsius to Fahrenheit produce the right answer? You test the MQTT payload formatter: does it create valid JSON? Each small piece is verified in isolation.”
Sammy the Sensor asked about hardware dependencies. “How do you test my sensor driver without real hardware?” Max explained, “Mocking! You create a fake version of the hardware interface that returns predictable values. When testing the temperature conversion, the mock always returns 25.0 degrees. This way, the test verifies the conversion math without needing a real sensor.”
Lila the LED described coverage. “Code coverage measures how much of your firmware has been tested. If your firmware has 100 functions and tests exercise 80 of them, you have 80% coverage. Safety-critical code like watchdog timers and fail-safe modes should have 100% coverage – no excuses.” Bella the Battery highlighted the cost benefit. “Unit tests catch about 70% of all bugs, and they are the cheapest tests to write and run. They execute in milliseconds on your development computer. Compare that to field testing which takes weeks and costs thousands of dollars. Always start with unit tests!”
3.2 Prerequisites
Before diving into this chapter, you should be familiar with:
- Testing Fundamentals: Understanding the testing pyramid and IoT challenges
- Prototyping Software: Familiarity with firmware development
Key Takeaway
In one sentence: Unit tests validate individual functions in isolation, catching 70% of bugs at the lowest cost.
Remember this rule: If hardware touches the function, mock it. If timing matters, inject time. If it’s critical, cover it 100%.
3.3 What to Unit Test
Unit tests validate individual functions in isolation, mocking all external dependencies (hardware, network, time).
Test these firmware components:
- Data processing algorithms
- Sensor value filtering (moving average, outlier detection)
- Data encoding/decoding (JSON, CBOR, Protobuf)
- State machines (device modes, protocol states)
- Business logic
- Threshold detection (temperature > 30°C → trigger alert)
- Event handling (button press → action mapping)
- Configuration validation (valid Wi-Fi SSID format)
- Protocol implementations
- MQTT packet encoding/decoding
- CoAP message parsing
- Checksum calculations
Don’t unit test hardware interactions directly—use integration tests for GPIO, I2C, SPI (requires real hardware).
3.4 Unit Testing Frameworks
Popular embedded unit test frameworks:
| Framework | Language | Features | Learning Curve |
|---|---|---|---|
| Unity | C | Lightweight, no dependencies | Easy |
| Google Test | C++ | Mature, extensive assertions | Moderate |
| CppUTest | C/C++ | Mock support, memory leak detection | Moderate |
| Embedded Unit | C | Minimal footprint for MCU | Easy |
3.4.1 Example: Testing with Unity Framework
Function to test - sensor filtering:
// sensor_filter.c - Function to test
#include "sensor_filter.h"
#define FILTER_SIZE 5
static float filter_buffer[FILTER_SIZE];
static int filter_index = 0;
static bool filter_full = false;
float apply_moving_average(float new_value) {
// Add new value to circular buffer
filter_buffer[filter_index] = new_value;
filter_index = (filter_index + 1) % FILTER_SIZE;
if (filter_index == 0) {
filter_full = true;
}
// Calculate average
float sum = 0;
int count = filter_full ? FILTER_SIZE : filter_index;
for (int i = 0; i < count; i++) {
sum += filter_buffer[i];
}
return sum / count;
}
void reset_filter(void) {
filter_index = 0;
filter_full = false;
}Unit tests for the filter:
// test_sensor_filter.c - Unit tests
#include "unity.h"
#include "sensor_filter.h"
void setUp(void) {
// Reset filter before each test
reset_filter();
}
void tearDown(void) {
// Clean up after each test
}
void test_first_value_returns_itself(void) {
float result = apply_moving_average(25.0);
TEST_ASSERT_EQUAL_FLOAT(25.0, result);
}
void test_two_values_return_average(void) {
apply_moving_average(20.0);
float result = apply_moving_average(30.0);
TEST_ASSERT_EQUAL_FLOAT(25.0, result);
}
void test_full_buffer_calculates_correct_average(void) {
// Fill buffer: [10, 20, 30, 40, 50]
apply_moving_average(10.0);
apply_moving_average(20.0);
apply_moving_average(30.0);
apply_moving_average(40.0);
float result = apply_moving_average(50.0);
// Average = (10 + 20 + 30 + 40 + 50) / 5 = 30
TEST_ASSERT_EQUAL_FLOAT(30.0, result);
}
void test_circular_buffer_wraps_correctly(void) {
// Fill buffer: [10, 20, 30, 40, 50]
for (int i = 1; i <= 5; i++) {
apply_moving_average(i * 10.0);
}
// Add 6th value (60) → replaces oldest (10)
// New buffer: [60, 20, 30, 40, 50]
float result = apply_moving_average(60.0);
// Average = (60 + 20 + 30 + 40 + 50) / 5 = 40
TEST_ASSERT_EQUAL_FLOAT(40.0, result);
}
void test_handles_negative_values(void) {
apply_moving_average(-10.0);
float result = apply_moving_average(10.0);
TEST_ASSERT_EQUAL_FLOAT(0.0, result);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_first_value_returns_itself);
RUN_TEST(test_two_values_return_average);
RUN_TEST(test_full_buffer_calculates_correct_average);
RUN_TEST(test_circular_buffer_wraps_correctly);
RUN_TEST(test_handles_negative_values);
return UNITY_END();
}Running the tests:
$ gcc test_sensor_filter.c sensor_filter.c unity.c -o test_runner
$ ./test_runner
test_sensor_filter.c:12:test_first_value_returns_itself:PASS
test_sensor_filter.c:17:test_two_values_return_average:PASS
test_sensor_filter.c:23:test_full_buffer_calculates_correct_average:PASS
test_sensor_filter.c:33:test_circular_buffer_wraps_correctly:PASS
test_sensor_filter.c:45:test_handles_negative_values:PASS
-----------------------
5 Tests 0 Failures 0 Ignored
OK3.5 Mocking Hardware Dependencies
Problem: Firmware interacts with hardware (GPIO, I2C sensors, timers). You can’t unit test this without real hardware.
Solution: Abstract hardware behind interfaces, then mock the interfaces in tests.
3.5.1 Example: Mocking a Temperature Sensor
Abstract interface:
// sensor_interface.h - Abstract interface
typedef struct {
float (*read_temperature)(void);
bool (*is_available)(void);
} SensorInterface;Production implementation (real hardware):
// sensor_hw.c
#include "sensor_interface.h"
#include "i2c_driver.h"
float hw_read_temperature(void) {
uint8_t data[2];
i2c_read_register(SENSOR_ADDR, TEMP_REG, data, 2);
int16_t raw = (data[0] << 8) | data[1];
return raw / 128.0; // Convert to Celsius
}
bool hw_is_available(void) {
return i2c_device_present(SENSOR_ADDR);
}
SensorInterface hw_sensor = {
.read_temperature = hw_read_temperature,
.is_available = hw_is_available
};Mock implementation for testing:
// test_mock_sensor.c - Mock for testing
#include "sensor_interface.h"
static float mock_temperature = 25.0;
static bool mock_available = true;
float mock_read_temperature(void) {
return mock_temperature;
}
bool mock_is_available(void) {
return mock_available;
}
SensorInterface mock_sensor = {
.read_temperature = mock_read_temperature,
.is_available = mock_is_available
};
// Helper functions for tests
void set_mock_temperature(float temp) {
mock_temperature = temp;
}
void set_mock_availability(bool available) {
mock_available = available;
}Now test business logic without hardware:
void test_temperature_alert_triggered(void) {
// Arrange: Set mock temperature to 35°C (above threshold)
set_mock_temperature(35.0);
// Act: Check if alert should trigger
bool alert = check_temperature_alert(&mock_sensor, 30.0);
// Assert: Alert should be triggered
TEST_ASSERT_TRUE(alert);
}
void test_sensor_unavailable_returns_error(void) {
// Arrange: Sensor not available
set_mock_availability(false);
// Act: Attempt to read temperature
SensorStatus status = read_sensor_safe(&mock_sensor);
// Assert: Should return error status
TEST_ASSERT_EQUAL(SENSOR_ERROR, status);
}Benefits:
- Tests run on your laptop (no hardware required)
- Tests run in milliseconds (no I2C delays)
- Tests are deterministic (no environmental noise)
- Can simulate sensor failures, edge cases
3.6 Code Coverage Targets
What is code coverage? Percentage of code lines executed during testing.
Coverage targets for IoT firmware:
| Code Category | Coverage Target | Rationale |
|---|---|---|
| Critical safety paths | 100% | Failure = injury/death (medical, automotive) |
| Core business logic | 85-95% | Bugs = product failure |
| Protocol implementations | 80-90% | Must handle edge cases |
| Utility functions | 70-80% | Lower risk |
| Hardware abstraction | 50-70% | Tested in integration tests |
Putting Numbers to It
Code coverage measures test completeness, but the relationship between coverage and defect detection is logarithmic — diminishing returns above 85%.
\[\text{Defect Detection Rate} \approx 1 - e^{-k \times \text{Coverage}}\]
For typical IoT firmware with defect detection constant \(k = 2.5\):
\[ \begin{align} \text{50% coverage:} & \quad 1 - e^{-2.5 \times 0.50} = 1 - e^{-1.25} = 71\% \text{ defects caught} \\ \text{85% coverage:} & \quad 1 - e^{-2.5 \times 0.85} = 1 - e^{-2.13} = 88\% \text{ defects caught} \\ \text{100% coverage:} & \quad 1 - e^{-2.5 \times 1.00} = 1 - e^{-2.50} = 92\% \text{ defects caught} \end{align} \]
Moving from 85% to 100% coverage (18% more effort) only catches 4% more defects. The optimal target is 85% for most code, 100% for safety-critical paths where every defect matters.
Example: Smart smoke detector firmware
Critical safety path (100% coverage required):
- Smoke detection algorithm
- Alert triggering logic
- Battery monitoring
Core business logic (90% coverage):
- Wi-Fi connection management
- Cloud reporting
- Configuration storage
Utility (70% coverage):
- LED blinking patterns
- Beep sound generation
Tools for measuring coverage:
| Tool | Platform | Integration |
|---|---|---|
| gcov/lcov | GCC | Generate HTML coverage reports |
| Bullseye | Embedded C | Commercial, supports MCU cross-compilation |
| Squish Coco | C/C++ | Source-based instrumentation |
Coverage does not equal Quality: 100% coverage doesn’t mean bug-free. It means every line was executed at least once—not that every edge case was tested.
3.7 Knowledge Check
Worked Example: Achieving 100% Coverage for Safety-Critical Dosing Algorithm
Scenario: Developing firmware for a connected insulin pump. The FDA requires documented evidence that safety-critical code paths have been thoroughly tested. Initial coverage: 73%.
Given:
- Total firmware: 45,000 lines of C code
- Safety-critical modules: 8,200 lines (glucose calculation, dosing algorithm, alert system)
- Dosing algorithm: 45 lines, 8 decision branches (6/8 tested)
- Target: 100% branch coverage for safety-critical, 85% for business logic
Analysis of uncovered branches:
$ gcov dosing_algorithm.c --branch-probabilities
Uncovered branches:
- Line 456: boundary case (glucose < 20 mg/dL) - never tested
- Line 678: dual-sensor disagreement (>15% difference) - never testedFinding: The two uncovered branches are both error handling and edge cases: 1. Sensor reading < 20 mg/dL (hypoglycemic boundary) 2. Dual-sensor disagreement > 15% (safety interlock)
Solution: Add targeted unit tests for boundary conditions:
// Test 1: Boundary condition
void test_glucose_at_hypoglycemic_boundary(void) {
set_mock_glucose(20); // Exactly at minimum valid
DoseResult result = calculate_dose(20, 100); // glucose, target
assert(result.status == DOSE_CALCULATED);
assert(result.units >= 0);
}
// Test 2: Dual-sensor disagreement
void test_dual_sensor_disagreement_triggers_alert(void) {
set_sensor_a_reading(120);
set_sensor_b_reading(145); // 20.8% difference
GlucoseResult result = get_calibrated_glucose();
assert(result.confidence == LOW);
assert(result.requires_fingerstick == true);
}Result: Coverage 73% → 100%. FDA audit pass.
Key Insight: High coverage in safety-critical systems requires intentional testing of failure modes, not just happy paths. 73% of uncovered branches are typically error handling and edge cases that developers forget to test.
Decision Framework: Allocating Unit Test Coverage by Code Risk
| Code Category | Coverage Target | Rationale | Example |
|---|---|---|---|
| Safety-critical | 100% | Regulatory requirement (FDA, ISO 26262) | Dosing algorithm, brake control |
| Core business logic | 85-95% | Bugs = product failure | MQTT reconnection, sensor reading |
| Protocol implementation | 80-90% | Must handle edge cases | CoAP message parsing |
| Utility functions | 70-80% | Lower risk, high reuse | String formatting, time conversion |
| Hardware abstraction | 50-70% | Tested in integration | GPIO drivers, I2C reads |
Key Insight: Uniform coverage treats all code equally. Risk-based allocation focuses effort where bugs cause the most damage.
Common Mistake: Confusing Code Coverage with Test Quality
The Mistake: Reporting “92% line coverage” and assuming the code is well-tested, while mutation testing reveals 35% of bugs escape detection.
Why It Happens: Coverage measures if code executed, not if behavior validated. Tests that call functions without assertions achieve coverage without catching bugs.
Example of False Coverage:
// Test that achieves 100% coverage but catches nothing
void test_temperature_conversion() {
float result = celsius_to_fahrenheit(25.0);
// NO ASSERTION - test passes regardless of result
}
// This test has coverage but would pass even if function returns garbageThe Fix: Use mutation testing to measure assertion strength. Mutate code (change > to >=, + to -) and verify tests fail. A 70% mutation score means 30% of bugs go undetected.
Key Insight: Coverage is necessary but not sufficient. Strong tests require both execution (coverage) and validation (assertions).
3.8 Summary
Unit testing forms the foundation of IoT quality assurance:
- Test pure logic: Data processing, business logic, protocol parsing
- Mock hardware: Abstract interfaces allow testing without physical devices
- Set risk-based coverage: 100% for safety-critical, 85%+ for core logic
- Measure quality, not just coverage: Use mutation testing to validate assertion strength
- Run fast: Unit tests should execute in seconds, enabling frequent runs
3.9 Knowledge Check
Try It Yourself: Unit Testing Exercise
Objective: Write unit tests for a sensor data averaging function with 100% branch coverage.
What You’ll Need:
- C compiler (gcc)
- Unity test framework (ThrowTheSwitch/Unity)
- Text editor or IDE
The Function to Test:
// sensor_filter.c
#include <stddef.h>
#include <stdbool.h>
#define FILTER_SIZE 5
static float filter_buffer[FILTER_SIZE];
static int filter_index = 0;
static bool filter_full = false;
float apply_moving_average(float new_value) {
filter_buffer[filter_index] = new_value;
filter_index = (filter_index + 1) % FILTER_SIZE;
if (filter_index == 0) {
filter_full = true;
}
float sum = 0;
int count = filter_full ? FILTER_SIZE : filter_index;
for (int i = 0; i < count; i++) {
sum += filter_buffer[i];
}
return sum / count;
}
void reset_filter(void) {
filter_index = 0;
filter_full = false;
}Your Task: Write tests that achieve 100% branch coverage:
- Test first value (1 value in buffer, not full)
- Test partial buffer (2-4 values, not full)
- Test full buffer (exactly 5 values)
- Test circular wrap (6th value overwrites oldest)
- Test negative values (ensure math works correctly)
- Test zero values (division by count)
Expected Test Output:
test_first_value_returns_itself:PASS
test_partial_buffer_averages_correctly:PASS
test_full_buffer_calculates_average:PASS
test_circular_buffer_wraps:PASS
test_handles_negative_values:PASS
test_handles_zero_values:PASS
6 Tests 0 Failures 0 Ignored
Coverage Check:
gcc -fprofile-arcs -ftest-coverage test_sensor_filter.c sensor_filter.c unity.c -o test_runner
./test_runner
gcov sensor_filter.cWhat to Observe: 100% line and branch coverage. Every if statement and loop executed at least once.
Challenge Extension: Add mutation testing - change > to >=, + to -, etc. in the source and verify tests fail.
Common Pitfalls
1. Testing Implementation Details Instead of Behavior
Unit tests that verify “function X calls function Y with argument Z” are brittle — they break whenever the implementation changes even if behavior is correct. Test observable behavior: given input A, the output is B. For IoT firmware: given a temperature ADC reading of 2048 (12-bit ADC, 3.3V ref, NTC thermistor), the formatted temperature string is “25.0°C”. Testing that “adc_to_temperature calls voltage_divider_calc” couples tests to implementation, preventing safe refactoring.
2. Not Mocking Non-Deterministic Hardware Dependencies
Firmware functions that read real sensors, get current time, or generate random numbers produce non-deterministic outputs that make unit tests unrepeatable. Replace non-deterministic hardware with mocks: inject a time source as a function pointer (mock returns a fixed timestamp), inject a sensor read function (mock returns a programmable sequence of values), inject an entropy source (mock returns deterministic test patterns). Non-determinism in unit tests is a design flaw in the hardware abstraction layer, not an inherent limitation.
3. Targeting 100% Line Coverage Instead of Branch Coverage
Line coverage (every line executed at least once) misses many bugs: a function with if-else has two branches but one line of code. For example: distance_alert = (distance < 10); is one line but has two branches (distance<10=true and distance<10=false). Target branch coverage (every true/false outcome of every conditional executed): 80% branch coverage finds significantly more bugs than 100% line coverage. Use gcov/lcov for C firmware coverage, focusing on branch coverage metrics.
4. Ignoring Setup and Teardown Causing Test State Pollution
Unit tests that leave global state modified (global variables, static function state, mock expectations not cleared) cause subsequent tests to fail with confusing errors. Symptoms: tests pass individually but fail when run together; test results depend on execution order. Use setUp() to initialize all state before each test and tearDown() to clean up after each test. In C with Unity/CMock: UnityBegin() before test, call MockModule_Init() in setUp, MockModule_Verify() and MockModule_Destroy() in tearDown.
3.10 What’s Next?
Continue your testing journey with these chapters:
- Integration Testing: Test hardware-software interactions and protocols
- Hardware-in-the-Loop Testing: Validate firmware against simulated sensor inputs
- Testing Overview: Return to the complete testing guide
| Previous | Current | Next |
|---|---|---|
| Testing Pyramid & Challenges | Unit Testing for IoT Firmware | Integration Testing for IoT Systems |