Skip to content
rusthacks.dev
Systems

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.

4 min read
Summarize in AI
Flat illustration for the post “Testing Embedded Rust on the Host With Mocks and Simulators”, composed around embedded.

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

Text
thermostat/
  Cargo.toml          workspace
  core/               #![no_std] library: logic and drivers, tested on host
  firmware/           binary for the chip: HAL setup, interrupts, main loop
rust
// 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

rust
#[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

rust
#[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 DelayNs or 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_section mutexes) is the enemy of tests; pass state into functions or own it in a struct.

Beyond the host

LevelToolCatches
Unit and logiccargo test on host with mocksAlgorithms, state machines, protocol parsing
Whole firmwareRenode or QEMUBoot, memory map, peripherals the simulator models
On-devicedefmt-test, embedded-test with probe-rsReal peripherals, timing, power behavior
Hardware in the loopTest rig with probes and fixturesElectrical 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.

Also inTesting
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.