Testing Embedded Rust on the Host With Mocks and Simulators
Keep logic in a no_std library, put hardware behind embedded-hal traits, and run the library tests on your computer. Use fakes for pins and buses, and reserve on-device tests for what only hardware can show.

Firmware has a reputation for being untestable because the code is tangled with registers. It does not have to be. The recipe is architectural: keep what the device decides apart from how it touches pins, and the decisions become ordinary Rust you can test with cargo test. This post shows the split and the tools. Versions: Rust 1.90, embedded-hal 1.0 and embedded-hal-mock 0.11.
The split
thermostat/
Cargo.toml workspace
core/ #![no_std] library: logic and drivers, tested on host
firmware/ binary for the chip: HAL setup, interrupts, main loop// core/src/lib.rs
#![cfg_attr(not(test), no_std)]
use embedded_hal::digital::OutputPin;
pub struct Thermostat<P: OutputPin> {
heater: P,
setpoint_c: i16,
hysteresis_c: i16,
heating: bool,
}
impl<P: OutputPin> Thermostat<P> {
pub fn new(heater: P, setpoint_c: i16, hysteresis_c: i16) -> Self {
Thermostat { heater, setpoint_c, hysteresis_c, heating: false }
}
pub fn update(&mut self, temp_c: i16) -> Result<bool, P::Error> {
if !self.heating && temp_c < self.setpoint_c - self.hysteresis_c {
self.heater.set_high()?;
self.heating = true;
} else if self.heating && temp_c >= self.setpoint_c {
self.heater.set_low()?;
self.heating = false;
}
Ok(self.heating)
}
}The controller is generic over any OutputPin. #![cfg_attr(not(test), no_std)] compiles it without std for the device but with std under cargo test, so the test harness works. The firmware crate passes a real HAL pin; tests pass a fake.
A test with a mock pin
#[cfg(test)]
mod tests {
use super::*;
use embedded_hal_mock::eh1::digital::{Mock as PinMock, State, Transaction};
#[test]
fn heats_below_band_and_stops_at_setpoint() {
let expectations = [
Transaction::set(State::High),
Transaction::set(State::Low),
];
let pin = PinMock::new(&expectations);
let mut t = Thermostat::new(pin, 20, 2);
assert_eq!(t.update(19).unwrap(), false); // inside the hysteresis band: stays off
assert_eq!(t.update(17).unwrap(), true); // below band: heater on
assert_eq!(t.update(19).unwrap(), true); // still below setpoint: stays on
assert_eq!(t.update(20).unwrap(), false); // reached setpoint: heater off
t.heater.done(); // verifies every expected transaction happened
}
}The mock holds a script of expected calls. A call not in the script, or a script step never reached, fails the test, so the test checks the interaction and not just the return value. Run with cargo test -p core (name your crate something other than core in real life, since the name clashes with the standard core). I2C and SPI mocks work the same way: script the bytes written and the bytes the "device" returns, then assert the driver decodes them correctly.
Fakes beat mocks for stateful devices
#[derive(Default)]
struct FakePin {
high: bool,
toggles: u32,
}
impl embedded_hal::digital::ErrorType for FakePin {
type Error = core::convert::Infallible;
}
impl embedded_hal::digital::OutputPin for FakePin {
fn set_low(&mut self) -> Result<(), Self::Error> {
self.toggles += (self.high) as u32;
self.high = false;
Ok(())
}
fn set_high(&mut self) -> Result<(), Self::Error> {
self.toggles += (!self.high) as u32;
self.high = true;
Ok(())
}
}A fake remembers state instead of asserting a script, so you can drive a longer scenario (a day of temperature readings) and assert properties: the heater never toggles more than N times, never turns on above the setpoint. For random inputs, combine with property testing (proptest).
Handling time, interrupts and static state
- Time: take a
DelayNsor a clock trait as a parameter, and use a fake that advances manually (injected clocks). - Interrupts: keep the handler a few lines: read the register, push an event into a queue or atomic. Test the code that processes events.
- Global state (
static mut,critical_sectionmutexes) is the enemy of tests; pass state into functions or own it in a struct.
Beyond the host
| Level | Tool | Catches |
|---|---|---|
| Unit and logic | cargo test on host with mocks | Algorithms, state machines, protocol parsing |
| Whole firmware | Renode or QEMU | Boot, memory map, peripherals the simulator models |
| On-device | defmt-test, embedded-test with probe-rs | Real peripherals, timing, power behavior |
| Hardware in the loop | Test rig with probes and fixtures | Electrical and integration behavior |
Run the host tests and a firmware build (cargo build --target thumbv7em-none-eabihf) in CI on every change (cross compiling for ARM); schedule slower simulator and hardware runs less often. Each level should find what the cheaper one cannot, so keep most tests at the bottom.


