Hardware abstraction layers are where embedded code often becomes difficult to test. I design traits around capabilities, then keep business logic independent from concrete drivers.
Unit tests run against mock implementations that simulate timing, failures, and edge values. This catches most logic regressions without hardware-in-the-loop.
I still maintain contract tests on real devices for integration guarantees. Those tests validate assumptions the mocks cannot represent, such as startup timing and peripheral quirks.
With this split, fast local feedback and high-confidence deployment can coexist.
A capability trait, not a device trait
The mistake I made early on was writing traits that mirrored a specific chip. Instead I model the capability the business logic needs, so any driver — or a mock — can satisfy it. For a temperature-driven controller, all the logic really needs is "give me a reading" and "tell me if it failed":
/// A capability the controller depends on, not a specific sensor.
pub trait TempSensor {
type Error;
/// Temperature in centidegrees Celsius (e.g. 21_50 == 21.50 C).
fn read_centi_c(&mut self) -> Result<i32, Self::Error>;
}
/// Business logic is written against the trait, never a concrete driver.
pub fn should_run_fan(sensor: &mut impl TempSensor, threshold_centi_c: i32) -> bool {
match sensor.read_centi_c() {
Ok(t) => t >= threshold_centi_c,
// Fail safe: if the sensor errors, keep the fan running.
Err(_) => true,
}
}
Keeping the trait this small is deliberate. It means the logic under test has no idea whether it is talking to an I2C part over embedded-hal or to a struct I invented for the test. On a real driver I usually implement TempSensor on top of the embedded-hal I2C traits, so the same interface holds from the host bench down to the microcontroller.
Mocking in a #[cfg(test)] block
Because the logic only sees the trait, the mock is a few lines. A scripted mock that returns queued readings (including an injected error) is enough to exercise the branches that matter — the threshold boundary and the fail-safe path:
#[cfg(test)]
mod tests {
use super::*;
struct MockSensor {
readings: std::vec::Vec<Result<i32, ()>>,
}
impl TempSensor for MockSensor {
type Error = ();
fn read_centi_c(&mut self) -> Result<i32, ()> {
// Pop the next scripted reading; panic if the test over-reads.
self.readings.remove(0)
}
}
#[test]
fn fan_turns_on_at_or_above_threshold() {
let mut s = MockSensor { readings: vec![Ok(30_00)] };
assert!(should_run_fan(&mut s, 30_00));
}
#[test]
fn fan_stays_off_below_threshold() {
let mut s = MockSensor { readings: vec![Ok(29_99)] };
assert!(!should_run_fan(&mut s, 30_00));
}
#[test]
fn sensor_error_fails_safe() {
let mut s = MockSensor { readings: vec![Err(())] };
assert!(should_run_fan(&mut s, 30_00));
}
}
These run with a plain cargo test on the host — no hardware, no timing flakiness — so they belong in CI and gate every merge. For anything more involved than a scripted list, embedded-hal-mock provides ready-made I2C/SPI/GPIO mocks with expectation checking, and mockall can derive a mock from the trait automatically. I only reach for those once a hand-written stub stops being the clearest thing in the file.
Contract tests on real hardware
Mocks encode my assumptions about the device, and assumptions drift from silicon. So I keep a small suite of contract tests that run against a real board — the boundary conditions a mock cannot honestly represent: power-on settling time before the first valid reading, how the part behaves when the bus glitches, and whether the datasheet's timing is what the part actually does. These are slow and need hardware, so I gate them behind a feature and run them on demand rather than on every push:
# Fast, hardware-free logic tests (run in CI on every commit)
cargo test
# Contract tests against a physically connected device (run on the bench)
cargo test --features hil -- --ignored --test-threads=1
The rule I hold to: the mock and the real device must satisfy the same trait, so the contract tests are really checking that my mock still tells the truth. When a contract test and its mirrored unit test disagree, the mock is the thing that is wrong, and I fix it there so the fast tests stay trustworthy.
Sources
- Imported from the previous version of the site (gaborl.hu). Original publication around 2024-09-09.
- embedded-hal — the standard trait set for portable embedded drivers.
- embedded-hal-mock and mockall — mocking crates for HAL traits and general traits.
Update history
- — Adopted from previous gaborl.hu (Hugo) site via legacy import.