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.xHost 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, fastMocking 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-firmwareQEMU-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-eabiTest 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-eabiState 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 --htmlCoverage 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 |