Testing Rust Embedded Firmware: defmt, probe-rs, QEMU, and Mocking HAL

Testing Rust Embedded Firmware: defmt, probe-rs, QEMU, and Mocking HAL

```tldr Rust's embedded ecosystem has first-class testing support: defmt-test runs tests directly on-target with RTT output, QEMU lets you run ARM Cortex-M tests on a developer machine, and embedded-hal-mock lets you unit test peripheral drivers without hardware at all. Most embedded Rust tests run on the host — no hardware required. ```

```takeaways Most logic tests run on the host with #[test]. Separate pure logic (state machines, protocol parsing, data transforms) from hardware access. Test logic on the host; test hardware interaction on-target.

embedded-hal-mock stubs GPIO, SPI, I2C, and UART. Write expected transactions, run your driver code, verify the mock's transaction log matches expectations.

defmt-test runs tests on real hardware. Tests compile to a firmware image, flash to the target, and results come back over RTT (Real-Time Transfer).

QEMU runs Cortex-M tests without hardware. cargo test under QEMU with qemu-system-arm gives fast, reproducible embedded test runs in CI.

Use #[cfg(test)] to swap HAL implementations. In test mode, inject the mock HAL; in production, use the real HAL trait implementation. ```

Project Structure

my-firmware/
  src/
    lib.rs          # pure logic — testable on host
    main.rs         # hardware entry point
    drivers/
      sensor.rs     # HAL-dependent driver
  tests/
    integration.rs  # on-target tests with defmt-test
  Cargo.toml
  memory.x

Host Unit Tests: Pure Logic

Code that doesn't touch hardware can be tested with standard cargo test:

// src/lib.rs
pub struct TemperatureSensor {
    calibration_offset: f32,
}

impl TemperatureSensor {
    pub fn new(calibration_offset: f32) -> Self {
        Self { calibration_offset }
    }

    pub fn convert_raw_to_celsius(&self, raw: u16) -> f32 {
        // Simplified: 10-bit ADC, 3.3V ref, LM35 sensor (10mV/°C)
        let voltage = (raw as f32 / 1023.0) * 3.3;
        let celsius = voltage / 0.01;
        celsius + self.calibration_offset
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn zero_raw_gives_offset_temperature() {
        let sensor = TemperatureSensor::new(2.5);
        assert_eq!(sensor.convert_raw_to_celsius(0), 2.5);
    }

    #[test]
    fn full_scale_gives_expected_celsius() {
        let sensor = TemperatureSensor::new(0.0);
        let result = sensor.convert_raw_to_celsius(1023);
        assert!((result - 330.0).abs() < 0.1);
    }

    #[test]
    fn negative_calibration_offset() {
        let sensor = TemperatureSensor::new(-5.0);
        assert!((sensor.convert_raw_to_celsius(512) - 160.9).abs() < 0.1);
    }
}
cargo test  # runs on host, fast

Mocking HAL With embedded-hal-mock

# Cargo.toml
[dev-dependencies]
embedded-hal-mock = { version = "0.10", features = ["eh1"] }

Testing an I2C Driver

// src/drivers/bme280.rs
use embedded_hal::i2c::I2c;

pub struct Bme280<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C: I2c> Bme280<I2C> {
    pub fn new(i2c: I2C, address: u8) -> Self {
        Self { i2c, address }
    }

    pub fn read_chip_id(&mut self) -> Result<u8, I2C::Error> {
        let mut buf = [0u8; 1];
        self.i2c.write_read(self.address, &[0xD0], &mut buf)?;
        Ok(buf[0])
    }

    pub fn is_valid_chip(&mut self) -> bool {
        self.read_chip_id().map(|id| id == 0x60).unwrap_or(false)
    }
}

// tests
#[cfg(test)]
mod tests {
    use super::*;
    use embedded_hal_mock::eh1::i2c::{Mock as I2cMock, Transaction};

    #[test]
    fn read_chip_id_sends_correct_register() {
        let expectations = vec![
            Transaction::write_read(0x76, vec![0xD0], vec![0x60]),
        ];
        let mock = I2cMock::new(&expectations);
        let mut sensor = Bme280::new(mock, 0x76);

        let chip_id = sensor.read_chip_id().unwrap();
        assert_eq!(chip_id, 0x60);
        sensor.i2c.done();  // verify all expected transactions occurred
    }

    #[test]
    fn is_valid_chip_returns_true_for_bme280() {
        let expectations = vec![
            Transaction::write_read(0x76, vec![0xD0], vec![0x60]),
        ];
        let mock = I2cMock::new(&expectations);
        let mut sensor = Bme280::new(mock, 0x76);

        assert!(sensor.is_valid_chip());
        sensor.i2c.done();
    }

    #[test]
    fn is_valid_chip_returns_false_for_wrong_id() {
        let expectations = vec![
            Transaction::write_read(0x76, vec![0xD0], vec![0xFF]),
        ];
        let mock = I2cMock::new(&expectations);
        let mut sensor = Bme280::new(mock, 0x76);

        assert!(!sensor.is_valid_chip());
        sensor.i2c.done();
    }
}

Testing SPI Transactions

use embedded_hal_mock::eh1::spi::{Mock as SpiMock, Transaction};

#[test]
fn spi_driver_sends_correct_frame() {
    let expectations = vec![
        Transaction::transfer(vec![0x01, 0x02], vec![0x00, 0xFF]),
    ];
    let mock = SpiMock::new(&expectations);
    let mut driver = MySpiDevice::new(mock);

    let result = driver.read_register(0x01);
    assert_eq!(result, 0xFF);
    driver.spi.done();
}

defmt-test: On-Target Tests

[dev-dependencies]
defmt-test = "0.3"
defmt = "0.3"
// tests/integration.rs
#![no_std]
#![no_main]

use defmt_test as _;

#[defmt_test::tests]
mod tests {
    use defmt::assert_eq;

    #[test]
    fn trivial_assertion() {
        assert_eq!(1 + 1, 2);
    }

    #[test]
    fn led_toggles() {
        // Use the actual hardware peripherals from the test harness
        let dp = nrf52840_hal::pac::Peripherals::take().unwrap();
        let port0 = nrf52840_hal::gpio::p0::Parts::new(dp.P0);
        let mut led = port0.p0_13.into_push_pull_output(nrf52840_hal::gpio::Level::Low);

        led.set_high().unwrap();
        // Can't assert the physical state without feedback, but tests
        // compile and link correctly against real peripherals
    }
}

Flash and run:

cargo test --test integration -- --chip nRF52840_xxAA
# or with probe-rs
probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/debug/test-firmware

QEMU-Based Testing in CI

For Cortex-M0/M3/M4 without hardware:

# .cargo/config.toml
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
# Install QEMU with ARM support
brew install qemu  # macOS
apt-get install qemu-system-arm  # Linux

# Run tests under QEMU
cargo test --target thumbv7m-none-eabi

Test binary runs in QEMU, exits via semihosting, returns pass/fail to the shell — no hardware needed.

GitHub Actions CI

name: Embedded Tests

on: [push, pull_request]

jobs:
  test-host:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test  # host tests

  test-qemu:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: thumbv7m-none-eabi
      - run: sudo apt-get install -y qemu-system-arm
      - run: cargo test --target thumbv7m-none-eabi

State Machine Testing

Embedded firmware often uses state machines. Test them on the host:

#[derive(Debug, PartialEq, Clone)]
pub enum DeviceState {
    Idle,
    Connecting,
    Connected,
    Error(ErrorCode),
}

#[derive(Debug)]
pub enum Event {
    ConnectRequested,
    Connected,
    Disconnected,
    Timeout,
    DataReceived(Vec<u8>),
}

pub struct Device {
    state: DeviceState,
}

impl Device {
    pub fn new() -> Self { Self { state: DeviceState::Idle } }

    pub fn handle_event(&mut self, event: Event) {
        self.state = match (&self.state, event) {
            (DeviceState::Idle, Event::ConnectRequested) => DeviceState::Connecting,
            (DeviceState::Connecting, Event::Connected) => DeviceState::Connected,
            (DeviceState::Connecting, Event::Timeout) => DeviceState::Error(ErrorCode::Timeout),
            (DeviceState::Connected, Event::Disconnected) => DeviceState::Idle,
            _ => self.state.clone(),
        };
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn idle_to_connecting_on_connect_request() {
        let mut device = Device::new();
        device.handle_event(Event::ConnectRequested);
        assert_eq!(device.state, DeviceState::Connecting);
    }

    #[test]
    fn connecting_to_error_on_timeout() {
        let mut device = Device::new();
        device.handle_event(Event::ConnectRequested);
        device.handle_event(Event::Timeout);
        assert_eq!(device.state, DeviceState::Error(ErrorCode::Timeout));
    }

    #[test]
    fn connected_to_idle_on_disconnect() {
        let mut device = Device::new();
        device.handle_event(Event::ConnectRequested);
        device.handle_event(Event::Connected);
        device.handle_event(Event::Disconnected);
        assert_eq!(device.state, DeviceState::Idle);
    }
}

Coverage for Embedded Rust

# Install cargo-llvm-cov
cargo install cargo-llvm-cov

# Run host tests with coverage
cargo llvm-cov --lcov --output-path lcov.info

# View report
cargo llvm-cov report --html

Coverage only works for host tests, not on-target or QEMU tests.

Key Crates Summary

Crate Purpose
embedded-hal-mock Mock GPIO, SPI, I2C, UART for unit tests
defmt-test On-target test framework with RTT output
probe-rs Flash and debug embedded targets from CI
panic-semihosting QEMU-compatible panic handler
cortex-m-semihosting Semihosting I/O for QEMU tests
proptest Property-based testing for host-side logic

Read more

Start now free