Chapter 15: Testing Embedded Code
Learning Objectives
This chapter covers:
- Test no_std code on your desktop using conditional compilation
- Create hardware abstraction layers (HAL) for testable embedded code
- Write unit tests for temperature data structures and algorithms
- Mock hardware dependencies for isolated testing
- Use integration tests to validate ESP32-C3 behavior
- Debug embedded code efficiently using both tests and hardware
Task: Test Embedded Code on Desktop
Building on chapters 13-14, where we created temperature monitoring with data structures, now we need to ensure our code is robust and correct.
Your Mission:
- Test no_std code on desktop using conditional compilation
- Mock hardware dependencies (temperature sensor, GPIO) for isolated testing
- Validate algorithms (circular buffer, statistics) without hardware
- Create testable abstractions that work both embedded and on desktop
- Add comprehensive test coverage including edge cases and error conditions
Why This Matters:
- Faster development: Test business logic without flashing hardware
- Better reliability: Catch bugs before they reach embedded systems
- Easier debugging: Desktop tools are more powerful than embedded debuggers
- Continuous Integration: Automated testing in CI/CD pipelines
The Challenge:
- Code runs on ESP32-C3 (RISC-V), but tests run on desktop (x86/ARM)
- No access to GPIO, sensors, or timers in test environment
- Need to test
no_stdcode usingstdtools
Conditional Compilation Strategy
The key insight: Your business logic doesn’t need hardware to be tested.
#![allow(unused)] fn main() { // This works in both embedded and test environments #[cfg(test)] use std::vec::Vec; // Tests can use std #[cfg(not(test))] use heapless::Vec; // Embedded uses heapless // The rest of your code works with either Vec! fn calculate_average(readings: &[f32]) -> Option<f32> { if readings.is_empty() { return None; } let sum: f32 = readings.iter().sum(); Some(sum / readings.len() as f32) } #[cfg(test)] mod tests { use super::*; #[test] fn test_average_calculation() { let readings = vec![20.0, 25.0, 30.0]; // std::vec in tests let avg = calculate_average(&readings).unwrap(); assert!((avg - 25.0).abs() < 0.01); } #[test] fn test_empty_readings() { let readings = vec![]; assert_eq!(calculate_average(&readings), None); } } }
Project Setup for Testable Embedded Code
First, let’s set up our project to support both embedded and testing targets:
[package]
name = "chapter15_testing"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
[[bin]]
name = "chapter15_testing"
path = "./src/bin/main.rs"
[lib]
name = "chapter15_testing"
path = "src/lib.rs"
[dependencies]
# Only include ESP dependencies when not testing
esp-hal = { version = "1.0.0", features = ["esp32c3", "unstable"], optional = true }
heapless = "0.8"
esp-println = { version = "0.16", features = ["esp32c3"], optional = true }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"], optional = true }
critical-section = "1.2.0"
[features]
default = ["esp-hal", "esp-println", "esp-bootloader-esp-idf"]
embedded = ["esp-hal", "esp-println", "esp-bootloader-esp-idf"]
[profile.dev]
opt-level = "s"
[profile.release]
codegen-units = 1
debug = 2
debug-assertions = false
incremental = false
lto = 'fat'
opt-level = 's'
overflow-checks = false
Key Setup Details:
- Optional ESP dependencies: Only included when building for embedded target
- Feature flags: Control when ESP-specific code is compiled
- Library + Binary: Allows testing the library separately from main embedded binary
Testing the Temperature Types from Chapter 14
Let’s add comprehensive tests to our embedded temperature code:
#![allow(unused)] fn main() { // src/lib.rs - Testable embedded temperature library #![cfg_attr(not(test), no_std)] use core::fmt; // Conditional imports for testing #[cfg(test)] use std::vec::Vec; #[cfg(not(test))] use heapless::Vec; /// Temperature reading optimized for embedded systems #[derive(Debug, Clone, Copy, PartialEq)] pub struct Temperature { // Store as i16 to save memory (16-bit vs 32-bit f32) // Resolution: 0.1°C, Range: -3276.8°C to +3276.7°C pub(crate) celsius_tenths: i16, } impl Temperature { /// Create temperature from Celsius value pub const fn from_celsius(celsius: f32) -> Self { Self { celsius_tenths: (celsius * 10.0) as i16, } } /// Get temperature as Celsius f32 pub fn celsius(&self) -> f32 { self.celsius_tenths as f32 / 10.0 } pub fn fahrenheit(&self) -> f32 { self.celsius() * 9.0 / 5.0 + 32.0 } pub const fn is_overheating(&self) -> bool { self.celsius_tenths > 500 // > 50°C } pub const fn is_normal_range(&self) -> bool { self.celsius_tenths >= 150 && self.celsius_tenths <= 350 // 15-35°C } } impl fmt::Display for Temperature { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:.1}°C", self.celsius()) } } pub struct TemperatureBuffer<const N: usize> { #[cfg(test)] readings: Vec<Temperature>, // std::vec for tests #[cfg(not(test))] readings: Vec<Temperature, N>, // heapless::vec for embedded total_readings: u32, } impl<const N: usize> TemperatureBuffer<N> { pub const fn new() -> Self { Self { readings: Vec::new(), total_readings: 0, } } pub fn push(&mut self, temperature: Temperature) { #[cfg(test)] { // In tests, we can grow unlimited if self.readings.len() >= N { self.readings.remove(0); } self.readings.push(temperature); } #[cfg(not(test))] { // In embedded, handle fixed capacity with circular buffer if self.readings.len() < N { self.readings.push(temperature).ok(); } else { // Use circular indexing (O(1) vs remove(0) which is O(n)) let oldest_index = (self.total_readings as usize) % N; self.readings[oldest_index] = temperature; } } self.total_readings += 1; } pub fn len(&self) -> usize { self.readings.len() } pub const fn capacity(&self) -> usize { N } pub fn latest(&self) -> Option<Temperature> { self.readings.last().copied() } pub fn average(&self) -> Option<Temperature> { if self.readings.is_empty() { return None; } let sum: i32 = self.readings.iter() .map(|t| t.celsius_tenths as i32) .sum(); let avg_tenths = sum / self.readings.len() as i32; Some(Temperature { celsius_tenths: avg_tenths as i16 }) } pub fn min(&self) -> Option<Temperature> { self.readings.iter() .min_by_key(|t| t.celsius_tenths) .copied() } pub fn max(&self) -> Option<Temperature> { self.readings.iter() .max_by_key(|t| t.celsius_tenths) .copied() } pub fn total_readings(&self) -> u32 { self.total_readings } } #[cfg(test)] mod tests { use super::*; #[test] fn test_temperature_creation_and_conversion() { let temp = Temperature::from_celsius(23.5); // Test precision assert!((temp.celsius() - 23.5).abs() < 0.1); // Test Fahrenheit conversion let fahrenheit = temp.fahrenheit(); assert!((fahrenheit - 74.3).abs() < 0.1); // Test memory efficiency assert_eq!(core::mem::size_of::<Temperature>(), 2); } #[test] fn test_temperature_ranges() { let normal = Temperature::from_celsius(25.0); assert!(normal.is_normal_range()); assert!(!normal.is_overheating()); let hot = Temperature::from_celsius(55.0); assert!(!hot.is_normal_range()); assert!(hot.is_overheating()); let cold = Temperature::from_celsius(5.0); assert!(!cold.is_normal_range()); assert!(!cold.is_overheating()); } #[test] fn test_temperature_edge_cases() { // Test extreme values let extreme_hot = Temperature::from_celsius(3276.0); let extreme_cold = Temperature::from_celsius(-3276.0); assert!(extreme_hot.celsius() > 3000.0); assert!(extreme_cold.celsius() < -3000.0); } #[test] fn test_buffer_basic_operations() { let mut buffer = TemperatureBuffer::<5>::new(); assert_eq!(buffer.len(), 0); assert_eq!(buffer.capacity(), 5); assert_eq!(buffer.latest(), None); // Add some readings buffer.push(Temperature::from_celsius(20.0)); buffer.push(Temperature::from_celsius(25.0)); buffer.push(Temperature::from_celsius(30.0)); assert_eq!(buffer.len(), 3); assert_eq!(buffer.total_readings(), 3); assert_eq!(buffer.latest().unwrap().celsius(), 30.0); } #[test] fn test_buffer_circular_behavior() { let mut buffer = TemperatureBuffer::<3>::new(); // Fill buffer exactly buffer.push(Temperature::from_celsius(10.0)); buffer.push(Temperature::from_celsius(20.0)); buffer.push(Temperature::from_celsius(30.0)); assert_eq!(buffer.len(), 3); // Add one more - should overwrite oldest buffer.push(Temperature::from_celsius(40.0)); assert_eq!(buffer.len(), 3); // Still full assert_eq!(buffer.total_readings(), 4); // But total increased // First reading (10.0) should be gone assert_eq!(buffer.min().unwrap().celsius(), 20.0); // Min is now 20 assert_eq!(buffer.max().unwrap().celsius(), 40.0); // Max is 40 } #[test] fn test_buffer_statistics() { let mut buffer = TemperatureBuffer::<10>::new(); // Add test data: 20, 21, 22, 23, 24 for i in 0..5 { buffer.push(Temperature::from_celsius(20.0 + i as f32)); } let avg = buffer.average().unwrap(); assert!((avg.celsius() - 22.0).abs() < 0.1); assert_eq!(buffer.min().unwrap().celsius(), 20.0); assert_eq!(buffer.max().unwrap().celsius(), 24.0); } #[test] fn test_buffer_empty_statistics() { let buffer = TemperatureBuffer::<5>::new(); assert_eq!(buffer.average(), None); assert_eq!(buffer.min(), None); assert_eq!(buffer.max(), None); } #[test] fn test_buffer_single_reading() { let mut buffer = TemperatureBuffer::<5>::new(); buffer.push(Temperature::from_celsius(25.0)); let avg = buffer.average().unwrap(); assert_eq!(avg.celsius(), 25.0); assert_eq!(buffer.min().unwrap().celsius(), 25.0); assert_eq!(buffer.max().unwrap().celsius(), 25.0); } #[test] fn test_temperature_display() { let temp = Temperature::from_celsius(23.7); let display_str = format!("{}", temp); assert_eq!(display_str, "23.7°C"); } #[test] fn test_memory_usage() { // Verify our types are memory efficient let temp_size = core::mem::size_of::<Temperature>(); let buffer_size = core::mem::size_of::<TemperatureBuffer<20>>(); println!("Temperature size: {} bytes", temp_size); println!("Buffer size (20 readings): {} bytes", buffer_size); assert_eq!(temp_size, 2); // Should be exactly 2 bytes // Buffer size will be larger in tests due to std::Vec } } }
Hardware Abstraction Layer (HAL) for Testing
To test hardware-dependent code, create an abstraction layer:
#![allow(unused)] fn main() { // src/hal.rs - Hardware abstraction layer #[cfg(test)] use std::cell::RefCell; /// Trait for reading temperature from any source pub trait TemperatureSensorHal { type Error; fn read_celsius(&mut self) -> Result<f32, Self::Error>; fn sensor_id(&self) -> &str; } /// Real ESP32 temperature sensor implementation #[cfg(not(test))] pub struct Esp32TemperatureSensor { sensor: esp_hal::temperature_sensor::TemperatureSensor, } #[cfg(not(test))] impl Esp32TemperatureSensor { pub fn new(sensor: esp_hal::temperature_sensor::TemperatureSensor) -> Self { Self { sensor } } } #[cfg(not(test))] impl TemperatureSensorHal for Esp32TemperatureSensor { type Error = (); fn read_celsius(&mut self) -> Result<f32, Self::Error> { Ok(self.sensor.read_celsius()) } fn sensor_id(&self) -> &str { "ESP32-C3 Built-in" } } /// Mock sensor for testing #[cfg(test)] pub struct MockTemperatureSensor { temperatures: RefCell<Vec<f32>>, current_index: RefCell<usize>, id: String, } #[cfg(test)] impl MockTemperatureSensor { pub fn new(id: String) -> Self { Self { temperatures: RefCell::new(vec![25.0]), // Default temperature current_index: RefCell::new(0), id, } } pub fn set_temperatures(&self, temps: Vec<f32>) { *self.temperatures.borrow_mut() = temps; *self.current_index.borrow_mut() = 0; } pub fn set_single_temperature(&self, temp: f32) { *self.temperatures.borrow_mut() = vec![temp]; *self.current_index.borrow_mut() = 0; } } #[cfg(test)] impl TemperatureSensorHal for MockTemperatureSensor { type Error = &'static str; fn read_celsius(&mut self) -> Result<f32, Self::Error> { let temps = self.temperatures.borrow(); let mut index = self.current_index.borrow_mut(); if temps.is_empty() { return Err("No temperature data configured"); } let temp = temps[*index]; *index = (*index + 1) % temps.len(); // Cycle through temperatures Ok(temp) } fn sensor_id(&self) -> &str { &self.id } } #[cfg(test)] mod tests { use super::*; #[test] fn test_mock_sensor_single_value() { let mut sensor = MockTemperatureSensor::new("test-sensor".to_string()); sensor.set_single_temperature(23.5); let temp1 = sensor.read_celsius().unwrap(); let temp2 = sensor.read_celsius().unwrap(); assert_eq!(temp1, 23.5); assert_eq!(temp2, 23.5); // Should repeat same value assert_eq!(sensor.sensor_id(), "test-sensor"); } #[test] fn test_mock_sensor_cycling_values() { let mut sensor = MockTemperatureSensor::new("cycle-test".to_string()); sensor.set_temperatures(vec![20.0, 25.0, 30.0]); assert_eq!(sensor.read_celsius().unwrap(), 20.0); assert_eq!(sensor.read_celsius().unwrap(), 25.0); assert_eq!(sensor.read_celsius().unwrap(), 30.0); assert_eq!(sensor.read_celsius().unwrap(), 20.0); // Cycles back } #[test] fn test_mock_sensor_empty_data() { let mut sensor = MockTemperatureSensor::new("empty-test".to_string()); sensor.set_temperatures(vec![]); assert!(sensor.read_celsius().is_err()); } } }
Integration Testing on Hardware
For testing actual hardware behavior, create integration tests:
#![allow(unused)] fn main() { // tests/integration_tests.rs - Hardware integration tests use temp_monitor::{Temperature, TemperatureBuffer}; #[cfg(target_arch = "riscv32")] // Only run on ESP32 #[test] fn test_hardware_sensor_reading() { // This test would run on actual ESP32 hardware // (Implementation depends on test framework like defmt-test) } // Cross-platform integration tests #[test] fn test_temperature_monitor_workflow() { // Test the complete workflow without hardware let mut buffer = TemperatureBuffer::<5>::new(); // Simulate sensor readings let readings = vec![22.0, 23.0, 24.0, 25.0, 26.0, 27.0]; for temp_celsius in readings { let temp = Temperature::from_celsius(temp_celsius); buffer.push(temp); } // Verify circular buffer behavior assert_eq!(buffer.len(), 5); assert_eq!(buffer.total_readings(), 6); // Verify statistics let stats = buffer.average().unwrap(); assert!((stats.celsius() - 25.0).abs() < 0.1); // Should be ~25°C average assert_eq!(buffer.min().unwrap().celsius(), 23.0); // Oldest (22.0) was overwritten assert_eq!(buffer.max().unwrap().celsius(), 27.0); } #[test] fn test_overheating_detection() { let normal_temp = Temperature::from_celsius(25.0); let hot_temp = Temperature::from_celsius(55.0); let very_hot_temp = Temperature::from_celsius(75.0); assert!(!normal_temp.is_overheating()); assert!(hot_temp.is_overheating()); assert!(very_hot_temp.is_overheating()); // Test with buffer let mut buffer = TemperatureBuffer::<3>::new(); buffer.push(normal_temp); buffer.push(hot_temp); buffer.push(very_hot_temp); // Should average to overheating territory let avg = buffer.average().unwrap(); assert!(avg.is_overheating()); } }
Running Tests
Desktop Tests
# Run all tests on desktop
cargo test
# Run specific test module
cargo test temperature::tests
# Run with output
cargo test -- --nocapture
# Run tests in verbose mode
cargo test --verbose
Test Output Example
$ cargo test
Compiling temp_monitor v0.1.0
Finished test [unoptimized + debuginfo] target(s) in 1.23s
Running unittests src/lib.rs
running 12 tests
test temperature::tests::test_temperature_creation_and_conversion ... ok
test temperature::tests::test_temperature_ranges ... ok
test temperature::tests::test_temperature_edge_cases ... ok
test temperature::tests::test_buffer_basic_operations ... ok
test temperature::tests::test_buffer_circular_behavior ... ok
test temperature::tests::test_buffer_statistics ... ok
test temperature::tests::test_buffer_empty_statistics ... ok
test temperature::tests::test_buffer_single_reading ... ok
test temperature::tests::test_temperature_display ... ok
test temperature::tests::test_memory_usage ... ok
test hal::tests::test_mock_sensor_single_value ... ok
test hal::tests::test_mock_sensor_cycling_values ... ok
Running tests/integration_tests.rs
running 2 tests
test test_temperature_monitor_workflow ... ok
test test_overheating_detection ... ok
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Building for Embedded Target
When you’re ready to test on hardware:
# Build and flash to ESP32-C3 (recommended)
cargo run --release --features embedded
# Alternative: Build then flash separately
cargo build --release --target riscv32imc-unknown-none-elf --features embedded
cargo espflash flash target/riscv32imc-unknown-none-elf/release/chapter15_testing
Key Testing Patterns Learned
✅ Conditional Compilation: Use #[cfg(test)] and #[cfg(not(test))] to create testable embedded code
✅ Hardware Abstraction: Create traits that can be mocked for testing hardware dependencies
✅ Memory Efficiency Testing: Verify size and memory usage in unit tests
✅ Edge Case Testing: Test boundary conditions like buffer overflow, empty data, extreme values
✅ Integration Testing: Test complete workflows without hardware dependencies
Next: In Chapter 16, we’ll add communication capabilities to send structured data like JSON over serial connections.
Hardware Validation
# Build and flash test version (recommended)
cargo run --release --features test-on-hardware
# Alternative: Build then flash
cargo build --release --features test-on-hardware
cargo espflash flash target/riscv32imc-unknown-none-elf/release/temp_monitor
# Expected hardware output:
# Running hardware validation...
# ✅ Temperature sensor responding
# ✅ LED control working
# ✅ Buffer operations correct
# ✅ Statistics calculation accurate
# Hardware tests passed!
Test-Driven Development for Embedded
Use TDD to develop new features:
#![allow(unused)] fn main() { // 1. Write failing test first #[test] fn test_temperature_trend_detection() { let mut buffer = TemperatureBuffer::<5>::new(); // Rising temperature trend buffer.push(Temperature::from_celsius(20.0)); buffer.push(Temperature::from_celsius(22.0)); buffer.push(Temperature::from_celsius(24.0)); // This will fail until we implement it assert_eq!(buffer.trend(), Some(TemperatureTrend::Rising)); } // 2. Implement minimal code to make test pass #[derive(Debug, PartialEq)] pub enum TemperatureTrend { Rising, Falling, Stable, } impl<const N: usize> TemperatureBuffer<N> { pub fn trend(&self) -> Option<TemperatureTrend> { if self.readings.len() < 3 { return None; } // Simple trend detection - compare first and last let first = self.readings.first().unwrap().celsius_tenths; let last = self.readings.last().unwrap().celsius_tenths; if last > first + 20 { // More than 2°C increase Some(TemperatureTrend::Rising) } else if last < first - 20 { // More than 2°C decrease Some(TemperatureTrend::Falling) } else { Some(TemperatureTrend::Stable) } } } // 3. Refactor and add more test cases }
Exercise: Add Comprehensive Testing
Add a full test suite to your temperature monitoring code.
Requirements
- Unit Tests: Test all temperature and buffer functions
- Mock Hardware: Create testable hardware abstraction
- Integration Tests: Test complete workflows
- Error Cases: Test edge cases and error conditions
- Performance: Verify memory usage and efficiency
Tasks
-
Setup Test Environment:
- Add conditional compilation for tests
- Create
src/lib.rsto expose modules for testing - Update
Cargo.tomlwith test dependencies
-
Unit Tests for Temperature:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_temperature_precision() { // TODO: Test 0.1°C precision } #[test] fn test_conversion_roundtrip() { // TODO: celsius -> internal -> celsius should be stable } #[test] fn test_extreme_temperatures() { // TODO: Test very hot and cold values } } } -
Unit Tests for Buffer:
#![allow(unused)] fn main() { #[test] fn test_buffer_capacity_limits() { // TODO: Test buffer behavior at capacity } #[test] fn test_statistics_accuracy() { // TODO: Verify min/max/average calculations } #[test] fn test_circular_replacement() { // TODO: Ensure oldest data is properly replaced } } -
Hardware Abstraction Tests:
- Create mock sensor implementation
- Test sensor trait with controlled data
- Verify error handling
-
Run and Validate:
- Execute test suite with
cargo test - Verify all tests pass
- Check test coverage
- Execute test suite with
Expected Test Results
running 15 tests
test temperature::tests::test_temperature_precision ... ok
test temperature::tests::test_conversion_roundtrip ... ok
test temperature::tests::test_extreme_temperatures ... ok
test temperature::tests::test_buffer_capacity_limits ... ok
test temperature::tests::test_statistics_accuracy ... ok
test temperature::tests::test_circular_replacement ... ok
test hal::tests::test_mock_sensor ... ok
test integration::test_complete_workflow ... ok
...
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Memory usage:
Temperature: 2 bytes
Buffer (20 readings): 86 bytes
Total: 88 bytes ✅
Success Criteria
- All unit tests pass on desktop
- Mock sensor provides controlled test data
- Integration tests verify complete workflows
- Edge cases are handled gracefully
- Memory usage is within expected bounds
- Tests run quickly (< 1 second total)
Extension Challenges
- Property-Based Testing: Use
quickcheckto test with random data - Benchmark Tests: Measure performance of temperature calculations
- Hardware-in-the-Loop: Run tests on actual ESP32 hardware
- Coverage Analysis: Use
cargo tarpaulinto measure test coverage - Fuzzing: Test with invalid input data
Debugging Embedded Code
Test-First Debugging
When hardware doesn’t behave as expected:
-
Write Test for Expected Behavior:
#![allow(unused)] fn main() { #[test] fn test_sensor_reading_should_be_realistic() { let reading = mock_esp32_reading(1500); // ADC value let temp = Temperature::from_sensor_raw(reading); assert!(temp.celsius() > 15.0 && temp.celsius() < 40.0); } } -
Run Test on Desktop to verify logic
-
Compare with Hardware output
-
Identify Discrepancy and fix
Serial Debug Output
#![allow(unused)] fn main() { // Add debug output to embedded code esp_println::println!("Debug: ADC raw = {}, converted = {}°C", raw_value, temperature.celsius()); // Compare with test expectations #[test] fn test_debug_conversion() { let temp = Temperature::from_sensor_raw(1500); println!("Test: ADC raw = 1500, converted = {}°C", temp.celsius()); // Should match hardware output } }
Test-Driven Hardware Validation
#![allow(unused)] fn main() { #[cfg(feature = "hardware-test")] pub fn validate_hardware() { // This function runs on hardware to validate assumptions let mut sensor = /* initialize real sensor */; for _ in 0..10 { let reading = sensor.read_celsius(); esp_println::println!("Hardware reading: {:.1}°C", reading); // Sanity checks assert!(reading > -50.0 && reading < 100.0, "Reading out of range"); } esp_println::println!("✅ Hardware validation passed"); } }
Key Takeaways
✅ Conditional Compilation: Use #[cfg(test)] to test no_std code on desktop
✅ Hardware Abstraction: Create traits to mock hardware dependencies
✅ Test Structure: Unit tests for logic, integration tests for workflows
✅ TDD for Embedded: Write tests first, even for hardware-dependent features
✅ Debug Strategy: Combine desktop tests with serial debugging on hardware
✅ Performance Testing: Verify memory usage and timing in tests
Next: In Chapter 16, we’ll add communication capabilities to send our temperature data in structured formats like JSON and binary protocols.