How to Test Embedded Rust Code

Run cargo test for standard projects or mdbook test with library paths for book-based embedded Rust code.

Testing embedded Rust starts on your laptop

You just flashed a firmware update. The LED blinks green. Victory. Then you change a line of logic in the sensor driver, re-flash, and the motor spins backward. Or worse, the device bricks and you have to pull out the programmer to recover it. Flashing takes minutes. Debugging on hardware takes hours. You need a way to verify your code without touching the chip every time you change a variable.

Embedded testing feels different because you don't have an operating system. There is no file system to write logs to. There is no standard library to rely on for utilities. Your code runs bare-metal on a microcontroller with limited RAM and specific register maps. The temptation is to skip tests and just flash the device. That approach works until you refactor a state machine and introduce a regression that only appears under load.

The solution is to split your testing strategy. Write logic tests that run on your laptop. Write hardware tests that run on the device or a simulator. Most bugs live in the logic. Catch them on the host where tests run in milliseconds. Verify the hardware interaction separately. This approach gives you speed without sacrificing coverage.

The host vs target split

Rust's tooling supports two distinct testing modes. Host tests run the test binary on your computer's architecture. Target tests compile the test binary for the embedded chip. Host tests are fast and use the full standard library. Target tests check that your code compiles and links correctly for the device, but they usually cannot run unless you have a simulator or an emulator like QEMU.

Start with host tests. They prove your algorithms, parsers, state machines, and data structures work correctly. You can use std features like Vec, String, and println! in host tests even if your embedded crate is no_std. The test harness is a separate binary. It has its own dependencies. You can enable std just for testing without polluting the embedded binary.

/// Sensor data parser for the embedded application.
pub struct SensorParser {
    buffer: [u8; 64],
}

impl SensorParser {
    /// Creates a new parser with an empty buffer.
    pub fn new() -> Self {
        Self { buffer: [0; 64] }
    }

    /// Parses a temperature reading from raw bytes.
    /// Returns the temperature in Celsius, or None if the data is invalid.
    pub fn parse_temperature(&self, data: &[u8]) -> Option<f32> {
        if data.len() < 4 {
            return None;
        }
        // WHY: Check the magic byte to ensure valid frame start.
        if data[0] != 0xAA {
            return None;
        }
        // WHY: Combine the two bytes into a 16-bit signed integer.
        let raw = i16::from_le_bytes([data[1], data[2]]);
        Some(raw as f32 / 10.0)
    }
}

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

    #[test]
    fn test_valid_temperature() {
        let parser = SensorParser::new();
        // WHY: Simulate a valid frame with 25.0 degrees Celsius.
        let data = [0xAA, 0x19, 0x00, 0x00];
        assert_eq!(parser.parse_temperature(&data), Some(25.0));
    }

    #[test]
    fn test_invalid_magic_byte() {
        let parser = SensorParser::new();
        // WHY: Ensure the parser rejects frames with wrong header.
        let data = [0xBB, 0x19, 0x00, 0x00];
        assert_eq!(parser.parse_temperature(&data), None);
    }
}

Run these tests with cargo test. The compiler builds a test binary for your host machine and executes it. You get immediate feedback. If the logic is wrong, the test fails before you ever touch the hardware.

Convention aside: Put unit tests in #[cfg(test)] modules at the bottom of your source files. Put integration tests in the tests/ directory. Integration tests import your crate as an external dependency. This forces you to use the public API and catches issues where private details leak out.

Writing tests in no_std crates

Embedded crates often use #![no_std]. This removes the standard library to save space and remove OS dependencies. The test harness, however, expects std by default. If you run cargo test on a no_std crate, the compiler might fail to link the test binary because it cannot find std.

You have two options. The first is to enable alloc for tests. The alloc crate provides heap allocation without the full standard library. This works if your tests need Vec or String but you want to stay close to no_std. The second option is to enable std just for tests. This is the most flexible approach. It lets you use all standard library features in tests while keeping the embedded binary no_std.

Add std to your dev-dependencies in Cargo.toml. Or use a #[cfg(test)] block to import std only when testing.

#![no_std]

/// Core logic for the embedded application.
pub fn calculate_checksum(data: &[u8]) -> u8 {
    data.iter().fold(0, |acc, &b| acc.wrapping_add(b))
}

#[cfg(test)]
mod tests {
    use super::*;
    // WHY: Import std only for the test harness. The main crate remains no_std.
    extern crate std;

    #[test]
    fn test_checksum_basic() {
        // WHY: Use std::vec::Vec in tests even though the crate is no_std.
        let data = std::vec![1, 2, 3, 4];
        assert_eq!(calculate_checksum(&data), 10);
    }
}

If you forget to handle no_std correctly, the compiler rejects the build with E0463 (can't find crate for std). This error appears when the test harness tries to link std but the crate configuration forbids it. The fix is to ensure std is available in the test configuration. Add std to dev-dependencies or use the extern crate std trick inside #[cfg(test)].

The test harness is your safety net. Don't let no_std rip it out.

Keeping documentation examples alive

If you are writing a tutorial, a book, or a project with code examples in Markdown, those examples can rot. You update the code but forget to update the docs. Readers copy the example and get compiler errors. Trust erodes fast.

The mdbook tool can test code blocks in your documentation. It extracts the Rust snippets and runs them. This ensures your docs always match the code. This is especially useful for embedded projects where you might have a trpl crate or other dependencies that the examples rely on.

For mdbook projects, you need to build external dependencies first. Then run mdbook test with the correct library path. This tells the test runner where to find the compiled crates.

cd packages/trpl && cargo build
mdbook test --library-path packages/trpl/target/debug/deps

The --library-path flag points to the directory containing the dependency libraries. Without it, mdbook test cannot resolve imports from external crates. This command validates every code block in your book. If a snippet fails to compile or run, the test suite reports the error.

Docs that don't compile are lies waiting to happen. Run mdbook test in your CI pipeline to catch drift early.

Mocking hardware behind traits

Host tests cannot access hardware registers. Your laptop doesn't have a GPIO controller or a UART peripheral. If your code calls hardware directly, you cannot test it on the host. The solution is to abstract the hardware behind a trait. The real implementation talks to the registers. A mock implementation returns fixed values or simulates behavior.

Define a trait for each hardware interaction. Implement the trait for the real hardware. Implement the trait for a mock struct used in tests. Your business logic depends on the trait, not the concrete hardware. This pattern lets you test the logic with predictable inputs.

/// Trait for reading temperature from a sensor.
pub trait TemperatureReader {
    /// Reads the current temperature in Celsius.
    fn read(&self) -> Result<f32, SensorError>;
}

/// Error type for sensor operations.
#[derive(Debug, PartialEq)]
pub enum SensorError {
    CommunicationFailed,
    InvalidData,
}

/// Real implementation that talks to hardware registers.
pub struct RealSensor {
    // WHY: In real code, this would hold register addresses or peripheral handles.
    _private: (),
}

impl TemperatureReader for RealSensor {
    fn read(&self) -> Result<f32, SensorError> {
        // WHY: This code would read from registers. It cannot run on the host.
        // Simulated hardware read for illustration.
        Ok(42.0)
    }
}

/// Mock implementation for testing.
pub struct MockSensor {
    pub value: f32,
    pub should_fail: bool,
}

impl TemperatureReader for MockSensor {
    fn read(&self) -> Result<f32, SensorError> {
        if self.should_fail {
            return Err(SensorError::CommunicationFailed);
        }
        Ok(self.value)
    }
}

/// Application logic that depends on the trait.
pub fn process_temperature(reader: &dyn TemperatureReader) -> String {
    match reader.read() {
        Ok(temp) => format!("Temperature: {:.1}C", temp),
        Err(_) => "Sensor error".to_string(),
    }
}

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

    #[test]
    fn test_process_temperature_success() {
        let mock = MockSensor { value: 25.5, should_fail: false };
        assert_eq!(process_temperature(&mock), "Temperature: 25.5C");
    }

    #[test]
    fn test_process_temperature_error() {
        let mock = MockSensor { value: 0.0, should_fail: true };
        assert_eq!(process_temperature(&mock), "Sensor error");
    }
}

This pattern scales. You can mock I2C buses, SPI devices, and interrupt handlers. The logic stays testable. The hardware stays isolated. When you are ready to test the real hardware, you swap in the real implementation and run on the device.

Mock everything that touches silicon. Test the logic, not the wiring.

Pitfalls and compiler errors

Embedded testing has specific traps. The first is cfg attributes. If you hide code behind #[cfg(feature = "hw")], that code does not exist when you run tests without the feature. Your tests might pass while the hardware code is broken. Always run tests with the relevant features enabled. Use cargo test --features hw to include conditional code.

The second trap is panic handling. In no_std crates, panics need a handler. The test harness usually provides one, but if you have a custom panic handler in your crate, it might interfere. Ensure your panic handler is compatible with the test environment. Or use #[cfg(not(test))] to exclude the custom handler during tests.

The third trap is temporary values. Embedded code often borrows data from static buffers or DMA regions. If you create a temporary value and try to borrow it, the compiler rejects the code with E0716 (temporary value dropped while borrowed). This error appears when a reference outlives the data it points to. Bind the value to a variable first.

// BAD: Temporary dropped while borrowed.
// let ptr = &get_buffer()[0];

// GOOD: Bind to a variable to extend lifetime.
let buffer = get_buffer();
let ptr = &buffer[0];

The compiler rejects mismatched types with E0308. This happens often when converting between integer sizes for register writes. Use explicit casts or the as keyword. The compiler will not guess your intent.

Trust the borrow checker. It usually has a point.

When to use which tool

Use cargo test for unit and integration tests that run on the host. This is your primary tool for verifying logic. It is fast and uses the full standard library.

Use cargo build --target to verify that your code compiles for the embedded device. This catches target-specific issues like missing features or register conflicts. It does not run the code.

Use mdbook test for projects with documentation examples. This keeps your code snippets accurate and buildable.

Use trait-based mocking for code that interacts with hardware. This allows you to test logic on the host without physical peripherals.

Use QEMU or hardware-in-the-loop for testing interrupt handlers, timing constraints, and memory layout. These tools simulate or exercise the actual architecture.

Use cargo test --features to include conditional compilation paths. This ensures hidden code is tested alongside the public API.

Where to go next