"You Cannot Unit Test Code That Runs on Hardware"

This is the standard objection, and it contains one true observation wrapped in a false conclusion. You cannot meaningfully unit test a function whose entire body is register writes. But firmware as a whole does not resist unit testing. In most embedded projects, 60 to 80 percent of the code is decision logic, state handling, protocol parsing, filtering, and arithmetic. None of that needs a microcontroller. It needs a compiler and a few minutes.

What makes people believe otherwise is architecture, not physics. When peripheral access is scattered through every function, nothing can run anywhere except on the target. Fix the architecture and the testing problem mostly dissolves.

Why It Genuinely Is Harder Than Testing a Web Service

The objection deserves a fair hearing, because embedded testing does face real obstacles that server-side developers never meet.

  • Hardware dependency. Code reads ADCs, toggles pins, and waits on DMA completions. On a host machine those addresses are meaningless.
  • Concurrency you did not write. Interrupts preempt at arbitrary points. A race between an ISR and main-loop code is not reproducible on demand, and a unit test running single-threaded on a PC will never see it.
  • Real time is part of the specification. A function that produces the right answer 3 ms late has failed. Correctness tests say nothing about that.

Every one of these is an argument for a specific technique, not for giving up.

The Move That Makes Everything Else Possible

Separate logic from hardware access. Concretely, structure firmware in three layers.

The hardware abstraction layer is thin and dumb. Functions like adc_read_channel, i2c_write, and gpio_set contain register access and nothing else, with no decisions, no loops over business rules, and no conditionals about what the value means. This layer is not unit tested; it is verified on real hardware.

The driver layer sits above it and talks to a device through the HAL interface, never directly to registers. A temperature sensor driver calls i2c_write and i2c_read and converts raw counts into degrees. Swap in a fake HAL and this layer is fully testable on a host: feed it the byte sequence a real sensor returns, including a corrupted one, and assert the output.

The application layer holds the actual product behavior and touches no hardware at all. Thresholds, alarm conditions, mode transitions, charge control, protocol framing. This is the code that must be right and it is entirely testable in a plain host compiler. Writing product behavior as an explicit state machine, as covered in state machines in firmware, makes this layer both simpler and dramatically easier to test, since every transition is a table entry with expected inputs and outputs.

The practical test for whether the separation is real: can you compile the application layer with the host compiler, with no vendor headers on the include path? If not, hardware knowledge has leaked upward.

How It Works in Practice

Pick a framework that suits C. Unity with CMock is the common minimal choice and generates mocks straight from your header files. Teams writing C++ generally use GoogleTest. In Rust, the tooling comes free with the language, which is one of the quieter advantages in the Rust versus C decision for embedded work.

Compile tests natively with GCC or Clang for the host, not for the target. Tests run in milliseconds, you get a real debugger, and sanitizers for memory and undefined behavior become available. Those sanitizers routinely find bugs that would have shipped, because on a microcontroller a buffer overrun silently corrupts a neighbor variable.

Mock the layer directly beneath the code under test. Testing the sensor driver means faking the I2C calls. Testing the charge controller means faking the sensor driver. Inject the dependency through a struct of function pointers or by link-time substitution.

Fake time explicitly. Never call a real delay in testable code. Pass a tick count in, or call an injected get_ticks. Then a two-hour timeout is tested in microseconds by advancing the fake clock, and timer rollover, the classic firmware bug that appears 49 days after installation, becomes a three-line test.

Wire the suite into continuous integration so every commit builds, runs the tests, and reports coverage. A suite that runs only when someone remembers is worth a fraction of one that gates a merge.

Aim coverage where it earns its keep: protocol parsers, safety interlocks, battery and charge logic, calibration math, and anything with a boundary condition. Chasing a coverage percentage across HAL wrappers produces numbers, not confidence, and eats the budget you needed for the layers below.

What Unit Tests Will Never Catch

Be honest about the limits or the suite will create false comfort.

They will not catch race conditions between an interrupt and main-loop code, which is why the discipline in interrupt handling matters more than test count. They will not catch stack overflow, priority inversion, or missed deadlines, all of which are properties of the running system and depend on choices made in the RTOS versus bare metal architecture. They will not catch a peripheral configured with the wrong clock divider, a sensor that behaves differently from its datasheet, brownout behavior, electrical noise, or a board that fails after four hours at 60 °C.

Unit tests are one layer. Above them sit integration tests on real hardware, hardware-in-the-loop testing that exercises firmware against simulated sensors and loads, long-duration soak tests, and fault injection where you pull power mid-write and yank a cable mid-transfer. A shipped product also needs the last-resort recovery described in watchdog timers, because something will eventually get through every layer.

What It Costs and What It Returns

Setting up the framework, build, and CI runs three to five engineer-days on a new project. Writing tests adds roughly 15 to 25 percent to the time spent on the code you choose to cover. Retrofitting onto an existing tangled codebase costs far more, which is the real argument for doing it from the first commit.

The return shows up as time not spent. A logic bug found by a host test costs minutes. The same bug found during bring-up costs hours with a debugger attached. Found in the field after shipping, it costs an emergency release, a staged rollout, and support load, and on a certified product it can cost re-verification. Against a typical firmware development budget, a test suite is one of the few line items that reliably reduces the total.

Setting Up a Firmware Test Strategy

Projects House structures firmware for testability and builds the surrounding test infrastructure: layer separation, mocking approach, host build and CI pipeline, and the hardware-in-the-loop rig for what unit tests cannot reach. Send your platform and current codebase state through our contact form.