Testing in Rust: Ensuring Industrial Software Quality Before Deployment
Why Testing Is Critical in Industrial Software
In industrial software, a bug is not just an inconvenience. A miscalculated pressure reading could cause a valve to stay open, damaging equipment or endangering workers. A mishandled alarm threshold could mean a critical alert is never raised.
Rust's built-in testing framework makes it straightforward to verify that sensor logic, alarm thresholds, calibration formulas, and state machines behave correctly before code reaches production hardware.
Testing is not optional in this domain. It is part of the engineering process.
Unit Tests with #[test]
Unit tests in Rust live inside the same file as the code they test, wrapped in a #[cfg(test)] module. This module is only compiled when running cargo test.
/// Converts a raw ADC reading (0-4095) to temperature in Celsius.
fn adc_to_celsius(raw: u16) -> f64 {
// Linear conversion: 0 = -40C, 4095 = 125C
-40.0 + (raw as f64 / 4095.0) * 165.0
}
/// Checks if a temperature is within the safe operating range.
fn is_safe_temperature(temp: f64) -> bool {
temp >= -20.0 && temp <= 80.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adc_minimum() {
let temp = adc_to_celsius(0);
assert_eq!(temp, -40.0);
}
#[test]
fn test_adc_maximum() {
let temp = adc_to_celsius(4095);
assert!((temp - 125.0).abs() < 0.01, "Expected ~125.0, got {temp}");
}
#[test]
fn test_safe_range() {
assert!(is_safe_temperature(25.0));
assert!(is_safe_temperature(-20.0)); // boundary
assert!(!is_safe_temperature(-21.0));
assert!(!is_safe_temperature(81.0));
}
}
Run with cargo test. Rust discovers all #[test] functions automatically.
Testing Error Conditions
Industrial code must handle invalid inputs gracefully. Use #[should_panic] to verify that dangerous conditions are caught, and test Result types for proper error handling.
#[derive(Debug, PartialEq)]
enum SensorError {
OutOfRange,
Disconnected,
}
fn validate_pressure(bar: f64) -> Result<f64, SensorError> {
if bar < 0.0 || bar > 300.0 {
return Err(SensorError::OutOfRange);
}
Ok(bar)
}
fn critical_shutdown(pressure: f64) {
if pressure > 250.0 {
panic!("Emergency shutdown: pressure {pressure} bar exceeds limit");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_pressure() {
assert_eq!(validate_pressure(100.0), Ok(100.0));
}
#[test]
fn test_negative_pressure_rejected() {
assert_eq!(validate_pressure(-5.0), Err(SensorError::OutOfRange));
}
#[test]
fn test_overpressure_rejected() {
assert_eq!(validate_pressure(350.0), Err(SensorError::OutOfRange));
}
#[test]
#[should_panic(expected = "Emergency shutdown")]
fn test_critical_shutdown_triggers() {
critical_shutdown(260.0);
}
}
Integration Tests in tests/
Integration tests live in a separate tests/ directory at the project root. Each file there is compiled as its own crate, testing your library from the outside just like an external user would.
my_project/
src/
lib.rs
tests/
sensor_pipeline_test.rs
// tests/sensor_pipeline_test.rs
use my_project::{SensorReading, AlarmEngine, AlarmLevel};
#[test]
fn high_temperature_triggers_critical_alarm() {
let reading = SensorReading {
sensor_id: 1,
value: 95.0,
unit: "celsius",
};
let engine = AlarmEngine::new();
let alarm = engine.evaluate(&reading);
assert_eq!(alarm.level, AlarmLevel::Critical);
assert!(alarm.message.contains("temperature"));
}
#[test]
fn normal_reading_produces_no_alarm() {
let reading = SensorReading {
sensor_id: 1,
value: 25.0,
unit: "celsius",
};
let engine = AlarmEngine::new();
let alarm = engine.evaluate(&reading);
assert_eq!(alarm.level, AlarmLevel::None);
}
Integration tests verify that modules work together correctly, which is essential when sensor data flows through validation, processing, and alarm stages.
Doc Tests: Documentation That Compiles
Rust compiles and runs code examples inside /// doc comments. This guarantees your documentation stays accurate as the code evolves.
/// Applies a calibration offset to a raw sensor reading.
///
/// # Examples
///
/// ```
/// let raw = 100.0;
/// let offset = -2.5;
/// let calibrated = apply_offset(raw, offset);
/// assert_eq!(calibrated, 97.5);
/// ```
pub fn apply_offset(raw: f64, offset: f64) -> f64 {
raw + offset
}
/// Converts PSI to Bar.
///
/// # Examples
///
/// ```
/// let bar = psi_to_bar(14.5);
/// assert!((bar - 1.0).abs() < 0.01);
/// ```
pub fn psi_to_bar(psi: f64) -> f64 {
psi * 0.0689476
}
Running cargo test executes these examples. If someone changes apply_offset and the example breaks, the test fails immediately.
Test Organization and cargo test Flags
As your project grows, you need control over which tests run and how output is displayed.
# Run all tests
cargo test
# Run only tests with "pressure" in the name
cargo test pressure
# Show println! output (normally captured)
cargo test -- --nocapture
# Run only unit tests (skip integration tests)
cargo test --lib
# Run only integration tests
cargo test --test sensor_pipeline_test
# Run tests in a specific module
cargo test sensor::tests
Organize tests into focused modules so you can run fast feedback loops during development:
#[cfg(test)]
mod tests {
mod temperature_tests {
use super::super::*;
#[test]
fn test_freezing_point() { /* ... */ }
}
mod pressure_tests {
use super::super::*;
#[test]
fn test_atmospheric() { /* ... */ }
}
}
Practical Example: Testing a Sensor Calibration Module
A complete calibration module with thorough tests covering normal operation, edge cases, and error conditions.
#[derive(Debug, PartialEq)]
pub struct CalibrationProfile {
pub offset: f64,
pub scale: f64,
}
#[derive(Debug, PartialEq)]
pub enum CalibrationError {
ScaleZero,
InvalidReference,
}
pub fn create_profile(raw: f64, reference: f64) -> Result<CalibrationProfile, CalibrationError> {
if reference <= 0.0 {
return Err(CalibrationError::InvalidReference);
}
let scale = reference / raw;
if scale.abs() < f64::EPSILON {
return Err(CalibrationError::ScaleZero);
}
let offset = reference - (raw * scale);
Ok(CalibrationProfile { offset, scale })
}
pub fn apply_calibration(raw: f64, profile: &CalibrationProfile) -> f64 {
raw * profile.scale + profile.offset
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity_calibration() {
let profile = create_profile(100.0, 100.0).unwrap();
let result = apply_calibration(50.0, &profile);
assert!((result - 50.0).abs() < 0.001);
}
#[test]
fn test_scaled_calibration() {
let profile = create_profile(50.0, 100.0).unwrap();
let result = apply_calibration(25.0, &profile);
assert!((result - 50.0).abs() < 0.001);
}
#[test]
fn test_invalid_reference_rejected() {
let result = create_profile(100.0, -5.0);
assert_eq!(result, Err(CalibrationError::InvalidReference));
}
#[test]
fn test_zero_reference_rejected() {
let result = create_profile(100.0, 0.0);
assert_eq!(result, Err(CalibrationError::InvalidReference));
}
}
Summary
- Industrial software demands thorough testing because bugs can cause physical harm.
- Unit tests use
#[test]inside#[cfg(test)]modules alongside the code. - Test error conditions with
#[should_panic]and by asserting onResult::Err. - Integration tests in
tests/verify that modules work together correctly. - Doc tests in
///comments keep documentation and code in sync. - Use
cargo testflags to run specific tests and control output during development. - Build calibration and validation tests that cover normal values, boundaries, and invalid inputs.