Intro to Embedded Rust Part 9: Test-Driven Development
2026-03-19 | By ShawnHymel
Microcontrollers Raspberry Pi MCU
Test-driven development (TDD) is a software development methodology where you write automated tests before implementing the actual functionality. This approach, widely adopted in professional programming circles, helps catch bugs early, ensures code meets requirements, and provides living documentation of how your code should behave. While TDD is common in application development, it presents unique challenges in embedded systems where code runs on resource-constrained hardware and interacts directly with peripherals. In this tutorial, we'll explore how to apply TDD principles to embedded Rust by writing unit tests for our TMP102 driver library.
Note that all code for this series can be found in this GitHub repository.
The key challenge is that our driver depends on I2C communication, which normally requires real hardware. So, we'll learn how to create stub implementations that simulate I2C behavior, allowing us to test our driver code on your development computer without needing a physical sensor or microcontroller.
Overview of TDD

The TDD workflow follows a three-phase cycle often called "red-green-blue." In the red phase, you write a test for a new feature or bug fix before implementing any functional code. The test should fail because the feature doesn't exist yet. You might need to write minimal code scaffolding just to make the test compile and run.
In the green phase, you write just enough functional code to make the test pass (show green), focusing on correctness rather than optimization. Once the test passes, you enter the blue phase (refactoring), where you improve and optimize your code while ensuring tests continue to pass.
This cycle repeats: new requirements lead to new tests (red), implementation makes them pass (green), and refactoring improves the code quality (blue). The continuous feedback loop helps prevent bugs from creeping in and gives you confidence that changes don't break existing functionality.
Types of Tests
Automated tests are typically categorized into three levels based on scope:
- Unit tests verify individual functions, methods, or small units of code in isolation. They're fast, focused, and should test one specific behavior.
- Integration tests verify that multiple components work correctly together, such as ensuring a driver properly communicates with a HAL or that multiple modules interact as expected.
- System tests (also called end-to-end tests) verify that the entire application works correctly as a whole, often involving real hardware and complex test scenarios.
In embedded development, unit tests are often straightforward to implement and run on your development machine but may require some substitutions to simulate real hardware (e.g., I2C communication).
Integration and system tests become challenging since they often require actual hardware or more robust simulation/emulation. Hardware-in-the-loop (HIL) testing automates tests with real microcontrollers and peripherals, but it's complex to set up and maintain. For our TMP102 driver tests, we'll focus on unit tests that can run without hardware by substituting I2C functionality with test implementations.
Test Substitutions
When testing code that depends on external systems or hardware, you often need to create substitutes for those dependencies. The terminology varies across the testing community, but here are the common types you'll encounter.
- Dummy: a placeholder that gets passed around to satisfy function signatures but doesn't actually do anything. It's just there to fill the required parameters.
- Fake: provides a working implementation that takes shortcuts, making it unsuitable for production but useful for testing (like an in-memory database instead of a real one).
- Stub: provides minimal, predetermined responses to method calls, returning canned data without any real logic.
- Spy: similar to a stub but records information about how it was called, allowing you to verify that your code made the expected calls with the correct parameters.
- Mock: more sophisticated implementation, containing logic to verify that it's being used correctly and capable of failing tests if expectations aren't met.
For our TMP102 driver tests, we'll primarily use stubs and dummies: an I2C stub that implements the embedded-hal::i2c::I2c trait and returns predetermined sensor data, and a dummy error type to satisfy trait requirements. These allow us to test driver logic without real I2C hardware.
Creating Unit Tests
In workspace/libraries/tmp102-driver, open src/lib.rs. We are going to add our unit tests directly to this file. Here is what the final library source code (with tests) looks like:
#![no_std]
//! # TMP102 Demo Driver
//!
//! A simple demo driver for the TMP102 temperature sensor
use embedded_hal::i2c::I2c;
/// Custom error for our crate
#[derive(Debug)]
pub enum Error<E> {
/// I2C communication error
Communication(E),
}
/// Possible device addresses based on ADD0 pin connection
#[derive(Debug, Clone, Copy)]
pub enum Address {
Ground = 0x48, // Default
Vdd = 0x49,
Sda = 0x4A,
Scl = 0x4B,
}
impl Address {
/// Get the I2C address in u8 format
pub fn as_u8(self) -> u8 {
self as u8
}
}
/// List internal registers in a struct
#[allow(dead_code)]
struct Register;
#[allow(dead_code)]
impl Register {
const TEMPERATURE: u8 = 0x00;
const CONFIG: u8 = 0x01;
const T_LOW: u8 = 0x02;
const T_HIGH: u8 = 0x03;
}
/// TMP102 temperature sensor driver
pub struct TMP102<I2C> {
i2c: I2C,
address: Address,
}
impl<I2C> TMP102<I2C>
where
I2C: I2c,
{
/// Create a new TMP102 driver instance
pub fn new(i2c: I2C, address: Address) -> Self {
Self { i2c, address }
}
/// Create new instance with default address (Ground)
pub fn with_default_address(i2c: I2C) -> Self {
Self::new(i2c, Address::Ground)
}
/// Read the current temperature in degrees Celsius (blocking)
pub fn read_temperature_c(&mut self) -> Result<f32, Error<I2C::Error>> {
let mut rx_buf = [0u8; 2];
// Read from sensor
match self
.i2c
.write_read(self.address.as_u8(), &[Register::TEMPERATURE], &mut rx_buf)
{
Ok(()) => Ok(self.raw_to_celsius(rx_buf)),
Err(e) => Err(Error::Communication(e)),
}
}
/// Convert raw reading to Celsius
fn raw_to_celsius(&self, buf: [u8; 2]) -> f32 {
let temp_raw = ((buf[0] as u16) << 8) | (buf[1] as u16);
let temp_signed = (temp_raw as i16) >> 4;
(temp_signed as f32) * 0.0625
}
}
#[cfg(test)]
mod tests {
// Import top-level structs/functions
use super::*;
// Explicitly link to std
extern crate std;
// Test-only imports
use embedded_hal::i2c::{Error as I2cError, ErrorKind, Operation};
// I2C stub
#[derive(Debug)]
pub struct I2cStub {
pub response_data: [u8; 2],
pub call_count: usize,
}
// I2C bus with temperature sensor stub implementation
impl I2cStub {
// Create a new I2C bus
pub fn new() -> Self {
Self {
response_data: [0x00, 0x00],
call_count: 0,
}
}
// Set temperature (in Celsius)
pub fn set_temperature(&mut self, temp_c: f32) {
// Convert temperature to sensor format
let temp_raw = ((temp_c / 0.0625) as i16) << 4;
self.response_data[0] = (temp_raw >> 8) as u8;
self.response_data[1] = (temp_raw & 0xFF) as u8;
}
}
// Declare a dummy error type
#[derive(Debug, Clone)]
pub struct DummyError;
// Implement I2cError trait for the DummyError type
impl I2cError for DummyError {
fn kind(&self) -> ErrorKind {
ErrorKind::Other
}
}
// Associated type: use our dummy error type (for e.g. Self::Error)
impl embedded_hal::i2c::ErrorType for I2cStub {
type Error = DummyError;
}
// Stub mplementations of the basic I2C read/write functions
impl embedded_hal::i2c::I2c for I2cStub {
// Always return Ok
fn read(
&mut self,
_address: u8,
_read: &mut [u8]
) -> Result<(), Self::Error> {
Ok(())
}
// Always return Ok
fn write(
&mut self,
_address: u8,
_write: &[u8]
) -> Result<(), Self::Error> {
Ok(())
}
// Always return Ok
fn write_read(
&mut self,
_address: u8,
_write: &[u8],
read: &mut [u8],
) -> Result<(), Self::Error> {
// Return canned response
read.copy_from_slice(&self.response_data);
self.call_count += 1;
Ok(())
}
// Always return Ok
fn transaction(
&mut self,
_address: u8,
_operations: &mut [Operation<'_>],
) -> Result<(), Self::Error> {
Ok(())
}
}
// Unit test 1: create a new driver and make sure the device address is set
#[test]
fn test_new_driver() {
let i2c = I2cStub::new();
let driver = TMP102::new(i2c, Address::Ground);
assert_eq!(driver.address.as_u8(), 0x48);
}
// Unit test 2: Read the temperature using the I2C stub driver
#[test]
fn test_temperature_read() {
// Create a new I2C stub driver
let mut i2c = I2cStub::new();
// Set the temperature
i2c.set_temperature(25.0);
// Read the temperature
let mut driver = TMP102::new(i2c, Address::Ground);
let temp = driver.read_temperature_c().unwrap();
assert_eq!(temp, 25.0);
}
}
The test code lives in a special mod tests module at the bottom of lib.rs, enclosed within a #[cfg(test)] attribute. This attribute tells the compiler to only include this code when running tests with cargo test, not when building the library for production use.
Inside the tests module, we use "use super::*;" to import all the public items from our library (like TMP102, Address, and Error), and we include extern crate std; to explicitly link to the standard library. Normally, our library uses #![no_std] to work in embedded environments, but for tests running on your development computer, we need std for testing infrastructure. We also import items from embedded_hal::i2c that we'll need to implement our test stubs, including the I2c trait itself and error-related types.
The core of our test infrastructure is the I2cStub struct, which implements the embedded-hal::i2c::I2c trait without requiring actual hardware. This stub contains a response_data buffer that holds predetermined sensor readings and a call_count to track how many times the stub was called. We won’t use this call_count variable, but it exists should you want to add spy-like behavior to this stub later (e.g., tracking the number of times it was called).
We provide a set_temperature() method that converts a temperature in Celsius into the raw two-byte format the TMP102 uses, allowing us to configure what "data" the stub will return. To satisfy the trait requirements, we create a DummyError type that implements the I2cError trait. This is a true dummy in TDD terminology, as it exists only to fulfill type requirements and doesn't do anything meaningful. The I2cStub then implements ErrorType with type Error = DummyError, establishing what error type our stub uses.
The actual I2c trait implementation for I2cStub provides minimal, stub versions of all required methods. Most methods like read(), write(), and transaction() simply return Ok(()) since we don't need them for our tests.
The critical method is write_read(), which copies data from our response_data buffer into the provided read buffer and increments the call count. This simulates the I2C transaction that would normally read temperature data from the real sensor. With this infrastructure in place, we can write actual test functions marked with #[test].
The test_new_driver() function verifies that creating a driver with Address::Ground correctly sets the I2C address to 0x48. The test_temperature_read() function demonstrates a complete test: we create an I2C stub, configure it to return data representing 25.0°C, create a TMP102 driver with that stub, read the temperature, and use assert_eq! to verify we get back exactly 25.0. These tests run entirely on your development machine without any microcontroller or sensor hardware, demonstrating how proper abstraction through traits enables effective embedded testing.
Run Tests
To run all of the tests, you can simply run the following from the command line:
cargo test
This will compile and run all functions marked with #[test] inside modules decorated with #[cfg(test)].

Note that you can filter tests by providing a substring pattern. For example:
cargo test test_temperature_read
This runs only tests whose names contain test_temperature_read.
Cargo uses substring matching for test filtering. For example, to run any test functions with the string "new" in the name, you could write:
cargo test new
This will run just the test_new_driver() function (while skipping test_temperature_read()), because "new" appears in test_new_driver but not in test_temperature_read.
Conclusion
Test-driven development with stub implementations allows you to verify embedded driver logic without requiring physical hardware, catching bugs early, and enabling rapid iteration during development. By leveraging Rust's trait system and the embedded-hal abstractions, you can write comprehensive unit tests that run on your development machine using cargo test, giving you confidence that your drivers work correctly before ever flashing code to a microcontroller. This approach to automated testing is essential for professional embedded development, enabling continuous integration pipelines that verify code quality with every commit and making it easier to refactor and improve your drivers without fear of breaking existing functionality.
Find the full Intro to Embedded Rust series here.