Article header image.

Testing Async Logic in Embedded Rust

Published 2 minutes read

Writing device drivers for embedded Rust using the traits from embedded-hal or embedded-hal-async is a breeze.

Example

For a simple SPI driver, our driver only needs to accept a generic SpiDevice instead of the concrete type.

pub struct ExampleDriver<SPI> {
    spi: SPI,
}

impl<SPI> ExampleDriver<SPI>
    where SPI: SpiDevice
{
    pub fn new(spi: SPI) -> Self {
        Self { spi }
    }

    pub async fn read_byte(&mut self) -> Result<u8, SPI::Error> {
        let mut ret = [0_u8; 1];
        self.spi.transaction(&mut [Operation::Read(&mut ret)]).await?;
        ret[0]
    }
}

Testing

However, once we want to start to test the async logic (in our case, the read_byte function), we'll run into troubles.

Unlike other async runtimes, like tokio or smol, embassy is designed to be run on no_std targets, using ARM Cortex (or similar) microprocessors. Therefore, it lacks the equivalent to tokio::test, which is used to create an async runtime for testing.

#[test]
fn test_driver() {
    let expected = [
        SpiTransaction::transaction_start(),
        SpiTransaction::read_vec(vec![
            0b0101_0101,
        ]),
        SpiTransaction::transaction_end(),
    ];

    let mut driver = ExampleDriver::new(spi::Mock::new(&expected));
    let byte_read = driver.read_byte().await.unwrap();
    //                                 ^^^^^
    // `await` is only allowed inside `async` functions and blocks
    assert_eq!(byte_read, 0b0101_0101);
}

    // Assert that our driver did actually read a single byte.
    driver.destroy().done();
}

Solution

To solve this, we need to grab a few crates that can provide us with an async runtime that can drive our tests.

[dev-dependencies]
# for the mock of the SpiDevice
embedded-hal-mock = "0.11"
# provides a blocking executor for futures
futures-test = "0.3"
# required if we're using any mutex from `embassy-sync`
critical-section = { version = "1.2", features = ["std"] }

With futures-test we can replace our #[test] annotation to declare an async function that uses a blocking runtime to drive our futures:

#[futures_test::test]
async fn test_driver() {
    // ... test from above ...
}

With that, we can now test our async logic.

However, when testing more complex code that depends on the embassy ecosystem, we might require either more mocks or actually run some of the embassy logic on our host device.

For example, we might depend on timers from embassy-time. These can be driven on std targets with:

embassy-time = { version = "0.5", features = ["generic-queue-64", "std"] }

Conclusion

With a little bit of creativity we managed to test our async embedded Rust driver using the executor from futures-test.