Rust - Testing
Overview
Estimated time: 60–75 minutes
Master Rust's built-in testing framework for writing reliable code. Learn unit tests, integration tests, documentation tests, and test-driven development practices in Rust.
Learning Objectives
- Write and run unit tests with
#[test]
attribute - Use assert macros for comprehensive test validation
- Organize integration tests and documentation tests
- Apply test-driven development principles in Rust
Prerequisites
Unit Testing Basics
Your First Test
Use the #[test]
attribute to mark functions as tests:
// Basic test structure
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
#[test]
fn basic_addition() {
assert_eq!(5 + 3, 8);
assert_eq!(10 + 0, 10);
assert_eq!(-1 + 1, 0);
}
#[test]
fn basic_subtraction() {
assert_eq!(10 - 5, 5);
assert_eq!(0 - 0, 0);
assert_eq!(100 - 99, 1);
}
}
// Run tests with: cargo test
// Expected output shows tests passing
Assert Macros
Rust provides several assert macros for different types of testing:
// Functions to test
fn divide(a: f64, b: f64) -> Result {
if b == 0.0 {
Err("Cannot divide by zero".to_string())
} else {
Ok(a / b)
}
}
fn is_even(n: i32) -> bool {
n % 2 == 0
}
fn get_name(age: u32) -> Option {
if age >= 18 {
Some("Adult".to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_assertions() {
// assert! - tests boolean conditions
assert!(2 + 2 == 4);
assert!(is_even(4));
assert!(!is_even(3));
}
#[test]
fn test_equality_assertions() {
// assert_eq! - tests equality
assert_eq!(2 + 2, 4);
assert_eq!("hello".to_string(), "hello");
assert_eq!(vec![1, 2, 3], vec![1, 2, 3]);
// assert_ne! - tests inequality
assert_ne!(2 + 2, 5);
assert_ne!("hello", "world");
}
#[test]
fn test_result_handling() {
// Testing Ok results
let result = divide(10.0, 2.0);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 5.0);
// Testing Err results
let error_result = divide(10.0, 0.0);
assert!(error_result.is_err());
assert_eq!(error_result.unwrap_err(), "Cannot divide by zero");
}
#[test]
fn test_option_handling() {
// Testing Some values
let adult = get_name(25);
assert!(adult.is_some());
assert_eq!(adult.unwrap(), "Adult");
// Testing None values
let child = get_name(15);
assert!(child.is_none());
}
#[test]
fn test_with_custom_messages() {
let x = 5;
let y = 10;
assert!(x < y, "x should be less than y, but x={}, y={}", x, y);
assert_eq!(x + y, 15, "Addition failed: {} + {} should equal 15", x, y);
}
}
Testing Panics and Errors
Expected Panics
Test that code panics under certain conditions:
// Function that might panic
fn divide_ints(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Cannot divide by zero!");
}
a / b
}
fn get_element(vec: &Vec, index: usize) -> i32 {
if index >= vec.len() {
panic!("Index {} out of bounds for vector of length {}", index, vec.len());
}
vec[index]
}
#[cfg(test)]
mod panic_tests {
use super::*;
#[test]
#[should_panic]
fn test_divide_by_zero_panics() {
divide_ints(10, 0); // This should panic
}
#[test]
#[should_panic(expected = "Cannot divide by zero!")]
fn test_divide_panic_message() {
divide_ints(5, 0); // This should panic with specific message
}
#[test]
#[should_panic(expected = "out of bounds")]
fn test_vector_bounds_panic() {
let vec = vec![1, 2, 3];
get_element(&vec, 10); // This should panic
}
#[test]
fn test_normal_division() {
let result = divide_ints(10, 2);
assert_eq!(result, 5);
}
#[test]
fn test_normal_vector_access() {
let vec = vec![1, 2, 3, 4, 5];
let element = get_element(&vec, 2);
assert_eq!(element, 3);
}
}
Testing with Result Types
Tests can return Result
types for cleaner error handling:
use std::fs;
use std::io;
// Function that returns Result
fn read_config_file(path: &str) -> Result {
fs::read_to_string(path)
}
fn parse_number(s: &str) -> Result {
s.trim().parse()
}
#[cfg(test)]
mod result_tests {
use super::*;
#[test]
fn test_parse_valid_number() -> Result<(), std::num::ParseIntError> {
let result = parse_number("42")?;
assert_eq!(result, 42);
Ok(())
}
#[test]
fn test_parse_whitespace_number() -> Result<(), std::num::ParseIntError> {
let result = parse_number(" 123 ")?;
assert_eq!(result, 123);
Ok(())
}
#[test]
fn test_parse_negative_number() -> Result<(), std::num::ParseIntError> {
let result = parse_number("-456")?;
assert_eq!(result, -456);
Ok(())
}
#[test]
fn test_parse_invalid_number() {
let result = parse_number("not_a_number");
assert!(result.is_err());
// Can also test the specific error type
match result {
Err(e) => assert_eq!(e.kind(), &std::num::IntErrorKind::InvalidDigit),
Ok(_) => panic!("Expected an error"),
}
}
}
Organizing Tests
Test Module Organization
Structure tests in modules for better organization:
// Calculator implementation
pub struct Calculator {
memory: f64,
}
impl Calculator {
pub fn new() -> Self {
Calculator { memory: 0.0 }
}
pub fn add(&mut self, value: f64) -> f64 {
self.memory += value;
self.memory
}
pub fn subtract(&mut self, value: f64) -> f64 {
self.memory -= value;
self.memory
}
pub fn multiply(&mut self, value: f64) -> f64 {
self.memory *= value;
self.memory
}
pub fn divide(&mut self, value: f64) -> Result {
if value == 0.0 {
Err("Division by zero".to_string())
} else {
self.memory /= value;
Ok(self.memory)
}
}
pub fn clear(&mut self) {
self.memory = 0.0;
}
pub fn get_memory(&self) -> f64 {
self.memory
}
}
#[cfg(test)]
mod calculator_tests {
use super::*;
mod basic_operations {
use super::*;
#[test]
fn test_new_calculator() {
let calc = Calculator::new();
assert_eq!(calc.get_memory(), 0.0);
}
#[test]
fn test_addition() {
let mut calc = Calculator::new();
assert_eq!(calc.add(5.0), 5.0);
assert_eq!(calc.add(3.0), 8.0);
assert_eq!(calc.get_memory(), 8.0);
}
#[test]
fn test_subtraction() {
let mut calc = Calculator::new();
calc.add(10.0);
assert_eq!(calc.subtract(3.0), 7.0);
assert_eq!(calc.subtract(2.0), 5.0);
}
#[test]
fn test_multiplication() {
let mut calc = Calculator::new();
calc.add(4.0);
assert_eq!(calc.multiply(3.0), 12.0);
assert_eq!(calc.multiply(0.5), 6.0);
}
}
mod division_operations {
use super::*;
#[test]
fn test_division() {
let mut calc = Calculator::new();
calc.add(20.0);
let result = calc.divide(4.0);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 5.0);
}
#[test]
fn test_division_by_zero() {
let mut calc = Calculator::new();
calc.add(10.0);
let result = calc.divide(0.0);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Division by zero");
}
}
mod memory_operations {
use super::*;
#[test]
fn test_clear_memory() {
let mut calc = Calculator::new();
calc.add(100.0);
assert_eq!(calc.get_memory(), 100.0);
calc.clear();
assert_eq!(calc.get_memory(), 0.0);
}
#[test]
fn test_memory_persistence() {
let mut calc = Calculator::new();
calc.add(10.0);
calc.multiply(2.0);
calc.subtract(5.0);
assert_eq!(calc.get_memory(), 15.0);
}
}
}
Integration Tests
tests/ Directory Structure
Integration tests go in the tests/
directory and test your crate as an external user would:
// tests/integration_test.rs
use my_calculator::Calculator; // Assuming our crate is named my_calculator
#[test]
fn test_calculator_workflow() {
let mut calc = Calculator::new();
// Test a complete calculation workflow
calc.add(10.0);
calc.multiply(2.0);
calc.subtract(5.0);
let result = calc.divide(3.0);
assert!(result.is_ok());
// Should be (10 * 2 - 5) / 3 = 15 / 3 = 5
assert_eq!(result.unwrap(), 5.0);
}
#[test]
fn test_calculator_error_recovery() {
let mut calc = Calculator::new();
calc.add(100.0);
// Try division by zero
let error_result = calc.divide(0.0);
assert!(error_result.is_err());
// Memory should be unchanged after error
assert_eq!(calc.get_memory(), 100.0);
// Should be able to continue with other operations
calc.add(50.0);
assert_eq!(calc.get_memory(), 150.0);
}
#[test]
fn test_multiple_calculators() {
let mut calc1 = Calculator::new();
let mut calc2 = Calculator::new();
calc1.add(10.0);
calc2.add(20.0);
// Calculators should be independent
assert_eq!(calc1.get_memory(), 10.0);
assert_eq!(calc2.get_memory(), 20.0);
calc1.multiply(2.0);
// calc2 should be unaffected
assert_eq!(calc1.get_memory(), 20.0);
assert_eq!(calc2.get_memory(), 20.0);
}
Common Integration Test Patterns
Testing common integration scenarios:
// tests/common/mod.rs - Shared test utilities
use my_calculator::Calculator;
pub fn setup_calculator_with_value(value: f64) -> Calculator {
let mut calc = Calculator::new();
calc.add(value);
calc
}
pub fn perform_standard_operations(calc: &mut Calculator) -> f64 {
calc.add(10.0);
calc.multiply(2.0);
calc.subtract(5.0);
calc.get_memory()
}
// tests/advanced_integration.rs
mod common;
use my_calculator::Calculator;
use common::{setup_calculator_with_value, perform_standard_operations};
#[test]
fn test_calculator_chain_operations() {
let operations = vec![
("add", 5.0),
("multiply", 3.0),
("subtract", 2.0),
("divide", 2.0),
];
let mut calc = Calculator::new();
let mut expected = 0.0;
for (op, value) in operations {
match op {
"add" => {
calc.add(value);
expected += value;
}
"multiply" => {
calc.multiply(value);
expected *= value;
}
"subtract" => {
calc.subtract(value);
expected -= value;
}
"divide" => {
calc.divide(value).unwrap();
expected /= value;
}
_ => panic!("Unknown operation: {}", op),
}
assert_eq!(calc.get_memory(), expected);
}
}
#[test]
fn test_using_common_utilities() {
let mut calc = setup_calculator_with_value(100.0);
assert_eq!(calc.get_memory(), 100.0);
let result = perform_standard_operations(&mut calc);
// 100 + 10 = 110, * 2 = 220, - 5 = 215
assert_eq!(result, 215.0);
}
Documentation Tests
Testing Code in Documentation
Rust can run code examples in documentation comments:
/// A simple calculator that maintains memory of the last result.
///
/// # Examples
///
/// Basic usage:
///
/// ```
/// use my_calculator::Calculator;
///
/// let mut calc = Calculator::new();
/// assert_eq!(calc.add(5.0), 5.0);
/// assert_eq!(calc.multiply(2.0), 10.0);
/// ```
///
/// Error handling:
///
/// ```
/// use my_calculator::Calculator;
///
/// let mut calc = Calculator::new();
/// calc.add(10.0);
///
/// let result = calc.divide(0.0);
/// assert!(result.is_err());
/// assert_eq!(result.unwrap_err(), "Division by zero");
/// ```
///
/// Memory operations:
///
/// ```
/// use my_calculator::Calculator;
///
/// let mut calc = Calculator::new();
/// calc.add(100.0);
/// calc.multiply(0.5);
/// assert_eq!(calc.get_memory(), 50.0);
///
/// calc.clear();
/// assert_eq!(calc.get_memory(), 0.0);
/// ```
pub struct Calculator {
memory: f64,
}
impl Calculator {
/// Creates a new calculator with memory initialized to zero.
///
/// # Examples
///
/// ```
/// use my_calculator::Calculator;
///
/// let calc = Calculator::new();
/// assert_eq!(calc.get_memory(), 0.0);
/// ```
pub fn new() -> Self {
Calculator { memory: 0.0 }
}
/// Adds a value to the current memory.
///
/// # Arguments
///
/// * `value` - The value to add
///
/// # Returns
///
/// The new memory value after addition
///
/// # Examples
///
/// ```
/// use my_calculator::Calculator;
///
/// let mut calc = Calculator::new();
/// assert_eq!(calc.add(5.0), 5.0);
/// assert_eq!(calc.add(3.0), 8.0);
/// ```
pub fn add(&mut self, value: f64) -> f64 {
self.memory += value;
self.memory
}
/// Divides the current memory by a value.
///
/// # Arguments
///
/// * `value` - The divisor
///
/// # Returns
///
/// `Ok(result)` if division is successful, `Err(message)` if dividing by zero
///
/// # Examples
///
/// ```
/// use my_calculator::Calculator;
///
/// let mut calc = Calculator::new();
/// calc.add(20.0);
///
/// let result = calc.divide(4.0);
/// assert!(result.is_ok());
/// assert_eq!(result.unwrap(), 5.0);
///
/// // Division by zero returns error
/// let error = calc.divide(0.0);
/// assert!(error.is_err());
/// ```
pub fn divide(&mut self, value: f64) -> Result {
if value == 0.0 {
Err("Division by zero".to_string())
} else {
self.memory /= value;
Ok(self.memory)
}
}
/// Gets the current memory value.
///
/// # Examples
///
/// ```
/// use my_calculator::Calculator;
///
/// let mut calc = Calculator::new();
/// calc.add(42.0);
/// assert_eq!(calc.get_memory(), 42.0);
/// ```
pub fn get_memory(&self) -> f64 {
self.memory
}
}
// Run documentation tests with: cargo test --doc
Test-Driven Development (TDD)
TDD Example: Building a String Validator
Following TDD principles: Red → Green → Refactor:
// Step 1: Write failing tests first (RED)
#[cfg(test)]
mod string_validator_tests {
use super::*;
#[test]
fn test_email_validation() {
assert!(is_valid_email("[email protected]"));
assert!(is_valid_email("[email protected]"));
assert!(!is_valid_email("invalid.email"));
assert!(!is_valid_email("@example.com"));
assert!(!is_valid_email("user@"));
}
#[test]
fn test_password_strength() {
assert_eq!(password_strength("abc"), PasswordStrength::Weak);
assert_eq!(password_strength("password123"), PasswordStrength::Medium);
assert_eq!(password_strength("Str0ng!Pass"), PasswordStrength::Strong);
assert_eq!(password_strength("VeryStr0ng!Password123"), PasswordStrength::VeryStrong);
}
#[test]
fn test_username_validation() {
assert!(is_valid_username("user123"));
assert!(is_valid_username("cool_user"));
assert!(!is_valid_username("ab")); // Too short
assert!(!is_valid_username("user@domain")); // Invalid characters
assert!(!is_valid_username("")); // Empty
}
}
// Step 2: Write minimal code to make tests pass (GREEN)
#[derive(Debug, PartialEq)]
pub enum PasswordStrength {
Weak,
Medium,
Strong,
VeryStrong,
}
pub fn is_valid_email(email: &str) -> bool {
email.contains('@') &&
email.contains('.') &&
!email.starts_with('@') &&
!email.ends_with('@') &&
email.len() > 3
}
pub fn password_strength(password: &str) -> PasswordStrength {
let length = password.len();
let has_upper = password.chars().any(|c| c.is_uppercase());
let has_lower = password.chars().any(|c| c.is_lowercase());
let has_digit = password.chars().any(|c| c.is_digit(10));
let has_special = password.chars().any(|c| !c.is_alphanumeric());
let criteria_met = [has_upper, has_lower, has_digit, has_special]
.iter()
.filter(|&&x| x)
.count();
match (length, criteria_met) {
(0..=6, _) => PasswordStrength::Weak,
(7..=10, 0..=2) => PasswordStrength::Medium,
(7..=10, 3..=4) => PasswordStrength::Strong,
(11.., 3..=4) => PasswordStrength::VeryStrong,
_ => PasswordStrength::Medium,
}
}
pub fn is_valid_username(username: &str) -> bool {
username.len() >= 3 &&
username.len() <= 20 &&
username.chars().all(|c| c.is_alphanumeric() || c == '_') &&
!username.is_empty()
}
// Step 3: Refactor with more comprehensive tests
#[cfg(test)]
mod comprehensive_tests {
use super::*;
mod email_tests {
use super::*;
#[test]
fn test_valid_emails() {
let valid_emails = vec![
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
];
for email in valid_emails {
assert!(is_valid_email(email), "Email should be valid: {}", email);
}
}
#[test]
fn test_invalid_emails() {
let invalid_emails = vec![
"plainaddress",
"@example.com",
"user@",
"[email protected]",
"",
];
for email in invalid_emails {
assert!(!is_valid_email(email), "Email should be invalid: {}", email);
}
}
}
mod password_tests {
use super::*;
#[test]
fn test_password_strength_progression() {
// Test progression from weak to very strong
assert_eq!(password_strength("abc"), PasswordStrength::Weak);
assert_eq!(password_strength("password"), PasswordStrength::Medium);
assert_eq!(password_strength("Password1"), PasswordStrength::Strong);
assert_eq!(password_strength("VeryStr0ng!Password"), PasswordStrength::VeryStrong);
}
#[test]
fn test_password_criteria() {
// Test specific criteria
assert_eq!(password_strength("UPPERCASE"), PasswordStrength::Medium);
assert_eq!(password_strength("lowercase"), PasswordStrength::Medium);
assert_eq!(password_strength("12345678"), PasswordStrength::Medium);
assert_eq!(password_strength("!@#$%^&*"), PasswordStrength::Medium);
}
}
mod username_tests {
use super::*;
#[test]
fn test_username_length_requirements() {
assert!(!is_valid_username("ab")); // Too short
assert!(is_valid_username("abc")); // Minimum length
assert!(is_valid_username("a".repeat(20).as_str())); // Maximum length
}
#[test]
fn test_username_character_requirements() {
assert!(is_valid_username("user123")); // Alphanumeric
assert!(is_valid_username("user_name")); // With underscore
assert!(!is_valid_username("user-name")); // With hyphen (invalid)
assert!(!is_valid_username("user@domain")); // With @ (invalid)
}
}
}
Test Configuration and Attributes
Conditional Compilation and Test Attributes
Control when and how tests run:
#[cfg(test)]
mod test_configuration {
use super::*;
#[test]
fn regular_test() {
assert_eq!(2 + 2, 4);
}
#[test]
#[ignore]
fn expensive_test() {
// This test is ignored by default
// Run with: cargo test -- --ignored
println!("This is an expensive test that takes a long time");
std::thread::sleep(std::time::Duration::from_secs(1));
assert!(true);
}
#[test]
#[should_panic(expected = "overflow")]
fn test_overflow_panic() {
// Test that should panic with specific message
let _result = i32::MAX + 1; // This may or may not overflow depending on build mode
panic!("overflow occurred");
}
#[test]
#[cfg(target_os = "linux")]
fn linux_specific_test() {
// This test only runs on Linux
assert!(true);
}
#[test]
#[cfg(debug_assertions)]
fn debug_only_test() {
// This test only runs in debug mode
assert!(true);
}
#[test]
fn test_with_timeout() -> Result<(), Box> {
// Tests can have timeouts (though not built-in, you'd use external crates)
let start = std::time::Instant::now();
// Simulate some work
std::thread::sleep(std::time::Duration::from_millis(100));
let duration = start.elapsed();
assert!(duration < std::time::Duration::from_secs(1));
Ok(())
}
}
// Custom test harness example
#[cfg(test)]
mod custom_test_setup {
use std::sync::Once;
static INIT: Once = Once::new();
fn setup() {
INIT.call_once(|| {
// Global test setup - runs once
println!("Setting up global test environment");
});
}
#[test]
fn test_with_setup() {
setup();
// Test code here
assert!(true);
}
#[test]
fn another_test_with_setup() {
setup();
// Test code here
assert!(true);
}
}
Common Pitfalls
⚠️ Testing Private Functions
Keep test modules in the same file to access private items:
// ❌ Wrong - can't access private functions from integration tests
// tests/integration_test.rs
// use my_crate::private_function; // ERROR: private_function is not public
// ✅ Correct - unit tests in same file can access private items
fn private_helper(x: i32) -> i32 {
x * 2
}
pub fn public_function(x: i32) -> i32 {
private_helper(x) + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_private_helper() {
assert_eq!(private_helper(5), 10); // Can access private function
}
#[test]
fn test_public_function() {
assert_eq!(public_function(5), 11);
}
}
⚠️ Over-Testing Implementation Details
Focus on testing behavior, not implementation:
// ❌ Bad - testing implementation details
struct Counter {
internal_vec: Vec, // Implementation detail
}
impl Counter {
fn add(&mut self, value: i32) {
self.internal_vec.push(value);
}
fn count(&self) -> usize {
self.internal_vec.len()
}
}
#[test]
fn bad_test() {
let mut counter = Counter { internal_vec: vec![] };
counter.add(1);
// ❌ Testing internal structure
assert_eq!(counter.internal_vec, vec![1]);
}
// ✅ Good - testing public behavior
#[test]
fn good_test() {
let mut counter = Counter { internal_vec: vec![] };
counter.add(1);
counter.add(2);
// ✅ Testing public interface
assert_eq!(counter.count(), 2);
}
Best Practices
Testing Guidelines
- Test behavior, not implementation - Focus on what the code does, not how
- Write descriptive test names - Test names should explain what's being tested
- One assertion per test - Makes failures easier to diagnose
- Use setup functions for common test data
- Test edge cases - Empty inputs, boundary values, error conditions
- Keep tests fast - Slow tests discourage running them frequently
Checks for Understanding
Question 1: What's the difference between unit tests and integration tests?
Answer:
- Unit tests: Test individual functions/modules in isolation, go in
#[cfg(test)]
modules within source files, can access private items - Integration tests: Test your crate as external users would, go in
tests/
directory, only access public API
Question 2: When should you use #[should_panic]
?
Answer: Use #[should_panic]
when testing that code correctly panics under specific error conditions. Include the expected
parameter to verify the panic message. This ensures your error handling works as expected.
Question 3: What are documentation tests and why are they useful?
Answer: Documentation tests are code examples in documentation comments that Rust can compile and run. They're useful because they:
- Ensure documentation examples actually work
- Provide real usage examples for users
- Catch breaking changes in public APIs
- Serve as additional tests for your code
Summary
Rust's testing framework provides comprehensive tools for reliable software development:
- Unit tests with
#[test]
attribute and assert macros - Integration tests in the
tests/
directory - Documentation tests in doc comments
- Test organization with modules and conditional compilation
- TDD practices for reliable development workflows