Testing Embedded Software: Unit, Integration & HIL

Firmware that can't be tested can't be trusted. Learn the test pyramid for embedded, unit testing C on the host (Unity, CppUTest, GoogleTest), test doubles (fakes, stubs, mocks) for hardware, test-driven development, code coverage, static analysis, hardware-in-the-loop (HIL) and continuous integration, and how testing maps to safety standards.

Testing only on the target board, by hand, is slow and misses edge cases. Most firmware logic — parsers, state machines, control algorithms, CRCs — doesn't need hardware at all and can run thousands of tests per second on a PC.

Hardware-dependent parts are tested with fakes on the host, then integrated on the target, and finally the whole device is exercised by a hardware-in-the-loop rig that simulates its environment. Automated in CI, this catches regressions before they reach customers.

An analogy

Testing is like building a car: you test each part on a bench (unit tests), then subassemblies (integration), then the whole car on a rolling road (HIL), before driving it on real roads (field tests). Finding a faulty bolt on the bench is far cheaper than on the motorway.

Why test off-target?

Host tests run in milliseconds, can use debuggers and sanitizers (AddressSanitizer finds buffer overflows), and run automatically on every commit. Design firmware so logic is separate from hardware access — the layered architecture and BSP make this natural.

Test doubles

Replace hardware and dependencies with controllable versions:

Integration and HIL

Integration tests run on the target with real peripherals (loopback UART, known ADC voltage). Hardware-in-the-loop rigs connect the device to programmable power supplies, signal generators, CAN simulators and relays controlled by scripts (pytest, Robot Framework), simulating sensors and plant dynamics to test full behaviour, fault injection and timing — automatically, overnight.

Coverage, static analysis and standards

Measure statement and branch coverage (gcov/lcov) to find untested logic; safety standards require more (ISO 26262 ASIL D and DO-178C Level A require MC/DC). Static analysis (cppcheck, clang-tidy, MISRA checkers, Polyspace) proves the absence of classes of bugs. CI pipelines build for all targets, run unit tests, static analysis and HIL, and publish reports — creating the traceability auditors need.

Formulas

Worked example: Choosing test values

Given: Function clamp(x, lo, hi)

Find: Minimum test set.

Answer: 5 cases: lo−1, lo, mid, hi, hi+1

Worked example: Branch coverage

Given: 12 decision outcomes, tests exercise 9

Find: Coverage.

Answer: 75% — add tests for the 3 missing outcomes

Worked example: HIL fault injection

Given: Battery-management ECU

Find: Tests hard to do on real hardware.

Answer: HIL injects faults safely and repeatably

Host unit test for a CRC-16/CCITT module

#include <stdio.h>
#include <stdint.h>
#include <string.h>

/* ---- unit under test (crc16.c) ---- */
uint16_t crc16_ccitt(const uint8_t *d, size_t n, uint16_t crc)
{
    while (n--) {
        crc ^= (uint16_t)(*d++ << 8);
        for (int i = 0; i < 8; i++)
            crc = (crc & 0x8000u) ? (uint16_t)((crc << 1) ^ 0x1021u) : (uint16_t)(crc << 1);
    }
    return crc;
}

/* ---- minimal test framework ---- */
static int tests, fails;
#define CHECK_EQ(name, got, exp) do { tests++; if ((got) != (exp)) { fails++; \
    printf("FAIL %s: got 0x%04X expected 0x%04X\n", name, (unsigned)(got), (unsigned)(exp)); } \
    else printf("PASS %s\n", name); } while (0)

int main(void)
{
    const uint8_t *s = (const uint8_t *)"123456789";
    CHECK_EQ("standard check value", crc16_ccitt(s, 9, 0xFFFF), 0x29B1);
    CHECK_EQ("empty input returns init", crc16_ccitt(s, 0, 0xFFFF), 0xFFFF);
    uint16_t part = crc16_ccitt(s, 4, 0xFFFF);                 /* "1234"  */
    CHECK_EQ("incremental == one-shot", crc16_ccitt(s + 4, 5, part), 0x29B1);
    printf("%d tests, %d failures\n", tests, fails);
    return fails != 0;             /* non-zero exit fails the CI job */
}
/* Output:
PASS standard check value
PASS empty input returns init
PASS incremental == one-shot
3 tests, 0 failures                                                      */

The same crc16.c compiles for the MCU and for the PC. Known check values ('123456789' → 0x29B1) and properties (incremental = one-shot) make strong tests. Frameworks like Unity or CppUTest add fixtures, reporting and mocks.

Faking hardware for a driver test

/* The driver talks to an interface, not directly to HAL */
typedef struct { int (*read_reg)(uint8_t reg, uint8_t *buf, size_t n); } i2c_if_t;

/* Fake used only in tests: returns canned bytes */
static uint8_t fake_bytes[2];
static int fake_read(uint8_t reg, uint8_t *buf, size_t n) { memcpy(buf, fake_bytes, n); return 0; }

void test_temp_conversion_25C(void)
{
    i2c_if_t bus = { fake_read };
    fake_bytes[0] = 0x19; fake_bytes[1] = 0x00;           /* 25.00 C  */
    TEST_ASSERT_EQUAL_INT16(2500, tmp102_read_centi(&bus));
}

void test_negative_temperature(void)
{
    i2c_if_t bus = { fake_read };
    fake_bytes[0] = 0xE7; fake_bytes[1] = 0x00;           /* -25.00 C */
    TEST_ASSERT_EQUAL_INT16(-2500, tmp102_read_centi(&bus));
}

Dependency injection (passing an interface) lets the real driver code run on a PC with fake bus data — including values that are hard to produce physically, such as −25 °C or sensor errors.

Common mistakes

Applications

Key takeaways

Going further