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:
- Stub: returns fixed values
- Fake: simple working implementation (RAM-backed 'Flash', simulated clock)
- Mock: records calls and verifies expectations (CMock, FFF, gMock)
- Spy: records outputs for later assertions (e.g. captured UART bytes)
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
- Test pyramid:
Many unit tests (ms each) > fewer integration tests > few HIL/system tests - Branch coverage:
covered decision outcomes / total outcomes × 100 % - MC/DC (safety-critical):
Each condition shown to independently affect the decision - Defect cost:
Cost to fix grows ~10× per phase (unit → integration → field)
Worked example: Choosing test values
Given: Function clamp(x, lo, hi)
Find: Minimum test set.
- Below range, at lo, inside, at hi, above
- Equivalence classes + boundaries
Answer: 5 cases: lo−1, lo, mid, hi, hi+1
Worked example: Branch coverage
Given: 12 decision outcomes, tests exercise 9
Find: Coverage.
- 9/12
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.
- Simulate cell over-voltage, sensor open circuit, CAN timeout
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
- Wrong: Logic mixed with register access → untestable. — Right: Separate logic from hardware via interfaces.
- Wrong: Only happy-path tests. — Right: Test boundaries, errors, timeouts and invalid input.
- Wrong: Manual testing before release only. — Right: Automate in CI on every change.
Applications
- Automotive (ISO 26262): Requirement-based tests, MC/DC coverage, HIL rigs.
- Medical (IEC 62304): Unit verification and traceability.
- Consumer IoT: CI with host tests and nightly device farms.
- Refactoring safety: Tests allow restructuring legacy firmware safely.
Key takeaways
- Separate logic from hardware to test on the host.
- Use fakes, stubs and mocks for dependencies.
- Automate unit, integration and HIL in CI.
- Coverage and static analysis support safety standards.
Going further
- Property-based & fuzz testing: Random inputs (libFuzzer) find parser crashes.
- Renode / QEMU: Simulated MCUs run unmodified firmware in CI.
- Mutation testing: Checks that tests actually detect injected bugs.