1576  Unit Testing for IoT Firmware

1576.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

1576.2 Prerequisites

Before diving into this chapter, you should be familiar with:

NoteKey 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%.


1576.3 What to Unit Test

Unit tests validate individual functions in isolation, mocking all external dependencies (hardware, network, time).

Test these firmware components:

  1. Data processing algorithms
    • Sensor value filtering (moving average, outlier detection)
    • Data encoding/decoding (JSON, CBOR, Protobuf)
    • State machines (device modes, protocol states)
  2. Business logic
    • Threshold detection (temperature > 30°C → trigger alert)
    • Event handling (button press → action mapping)
    • Configuration validation (valid Wi-Fi SSID format)
  3. 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).


1576.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

1576.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
OK

1576.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.

1576.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


1576.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

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.


1576.7 Worked Example: Achieving Coverage Targets

Scenario: Developing firmware for a connected insulin pump. The FDA requires documented evidence that safety-critical code paths have been thoroughly tested.

Given: - Total firmware: 45,000 lines of C code - Safety-critical modules: 8,200 lines (glucose calculation, dosing algorithm, alert system) - Target: 100% branch coverage for safety-critical, 85% for business logic

Analysis of uncovered branches:

$ gcov dosing_algorithm.c --branch-probabilities

Uncovered branches analysis:
- Line 234: else branch (invalid sensor reading) - never executed
- Line 456: boundary case (glucose < 20 mg/dL) - never tested
- Line 512: timeout path (sensor response > 5s) - never tested
- Line 678: dual-sensor disagreement (>15% difference) - never tested
- Line 823: battery critical during dose (<5%) - never tested

Finding: 73% of uncovered branches are error handling and edge cases

Test categories needed:

// Category 1: Boundary conditions (28% of new tests)
void test_glucose_at_lower_boundary(void) {
    // Test glucose = 20 mg/dL (minimum valid)
    set_mock_glucose(20);
    assert(calculate_dose() >= 0);
}

// Category 2: Error injection (35% of new tests)
void test_sensor_timeout_triggers_alert(void) {
    configure_mock_sensor_delay(6000);  // 6 second delay
    SensorResult result = read_glucose_with_timeout(5000);
    assert(result.status == SENSOR_TIMEOUT);
    assert(alert_triggered(ALERT_SENSOR_FAILURE));
}

// Category 3: State transitions (22% of new tests)
void test_dose_abort_on_battery_critical(void) {
    set_battery_level(4);  // 4% battery
    start_dose_delivery(5.0);  // 5 units
    assert(dose_state() == DOSE_ABORTED);
    assert(units_delivered() == 0);
}

// Category 4: Concurrent conditions (15% of new tests)
void test_dual_sensor_disagreement(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);
}

Key Insight: High coverage in safety-critical systems requires intentional testing of failure modes, not just happy paths.


1576.8 Knowledge Check


1576.9 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

1576.10 What’s Next?

Continue your testing journey with these chapters: