Writing Firmware Unit Tests You Can Actually Trust
"We test on hardware before every release" is necessary but not sufficient — by the time a bug reaches hardware-in-the-loop testing, it's already more expensive to find than it needed to be.
"We test on hardware before every release" is necessary but not sufficient — by the time a bug reaches hardware-in-the-loop testing, it's already more expensive to find than it needed to be.
Firmware testing has a reputation for being harder than application software testing, and in one sense that's true — firmware interacts with real hardware, timing, and interrupts in ways that are genuinely difficult to fully replicate off-target. But a large fraction of firmware logic — parsing, state machines, algorithms, data structures — doesn't actually need real hardware to test, and treating all firmware testing as requiring hardware-in-the-loop leaves a lot of fast, cheap test coverage on the table. That gap matters most in exactly the places where a bug is expensive to discover late — OTA update state machines and secure boot verification logic among them.
The single most impactful architectural decision for testability is a clean separation between hardware access (register reads/writes, peripheral drivers) and the logic that operates on the data those peripherals provide. Code structured this way — with hardware access behind a thin abstraction layer — allows the actual logic (a sensor fusion algorithm, a protocol parser, a state machine) to be compiled and tested on a development host, completely independent of target hardware. Code that mixes hardware register access directly into business logic can only be tested by running on the actual target, which is slower, harder to automate, and harder to debug when a test fails.
Once hardware access is abstracted, the logic above it can be compiled with a standard host compiler and tested using conventional unit-testing frameworks (Unity, CppUTest, GoogleTest, and similar are common in embedded contexts), running in seconds on a development machine or CI runner rather than requiring a physical board and a flash-and-run cycle for every test. This is the single biggest lever for making firmware testing fast enough that engineers actually run it constantly, rather than only right before a release.
For code that does need to exercise the interaction between logic and the hardware abstraction layer, mock implementations of that hardware interface (returning controlled, repeatable values instead of real sensor readings or peripheral state) allow integration-style tests to run on-host too — testing "does the driver correctly handle a sensor timeout" without needing to physically induce a timeout condition on real hardware. This closes a meaningful gap between pure unit tests and full hardware-in-the-loop tests.
Host-based testing doesn't replace hardware-in-the-loop testing — it narrows what hardware testing needs to cover, catching the large class of logic bugs earlier and faster, and leaving hardware testing to focus on what genuinely requires real hardware.
We architect firmware with a clean hardware-abstraction boundary specifically to enable fast, host-based test coverage, reserving hardware-in-the-loop testing for what actually needs it. See our firmware work for how this fits into a full engagement, from architecture through OTA-ready delivery.