Chapter 7: Advanced Testing

Comprehensive testing is crucial for production code. This chapter covers advanced testing techniques including property testing, fuzzing, mocking, and more.

7.1 Unit Testing Best Practices

Organizing Tests

// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add_positive() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn test_add_negative() {
        assert_eq!(add(-2, -3), -5);
    }

    #[test]
    #[should_panic(expected = "overflow")]
    fn test_add_overflow() {
        add(i32::MAX, 1);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // This test is ignored by default
        // Run with: cargo test -- --ignored
    }
}

// tests/integration_test.rs
#[test]
fn integration_test() {
    // Integration tests go in separate files
}

Custom Test Assertions

macro_rules! assert_approx_eq {
    ($a:expr, $b:expr, $epsilon:expr) => {
        let diff = ($a - $b).abs();
        assert!(
            diff < $epsilon,
            "assertion failed: {} โ‰ˆ {} (diff: {})",
            $a, $b, diff
        );
    };
}

#[test]
fn test_floating_point() {
    assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10);
}

// Using more_asserts crate
use more_asserts::{assert_gt, assert_le};

#[test]
fn test_ordering() {
    assert_gt!(5, 3);
    assert_le!(2, 5);
}

7.2 Property-Based Testing

Property-based testing generates random inputs to verify properties that should always hold:

use proptest::prelude::*;

fn reverse<T: Clone>(vec: &[T]) -> Vec<T> {
    vec.iter().rev().cloned().collect()
}

proptest! {
    #[test]
    fn test_reverse_twice_is_identity(vec in prop::collection::vec(any::<i32>(), 0..100)) {
        let reversed_twice = reverse(&reverse(&vec));
        prop_assert_eq!(vec, reversed_twice);
    }

    #[test]
    fn test_reverse_length(vec in prop::collection::vec(any::<i32>(), 0..100)) {
        let reversed = reverse(&vec);
        prop_assert_eq!(vec.len(), reversed.len());
    }

    #[test]
    fn test_addition_commutative(a in any::<i32>(), b in any::<i32>()) {
        prop_assume!(a.checked_add(b).is_some()); // Avoid overflow
        prop_assert_eq!(a + b, b + a);
    }
}

Custom Generators

use proptest::prelude::*;

#[derive(Debug, Clone)]
struct User {
    name: String,
    age: u8,
    email: String,
}

fn user_strategy() -> impl Strategy<Value = User> {
    (
        "[a-z]{3,10}",           // name: 3-10 lowercase letters
        1u8..100,                // age: 1-99
        "[a-z]{5,10}@[a-z]{3,7}\\.com",  // email
    )
        .prop_map(|(name, age, email)| User { name, age, email })
}

proptest! {
    #[test]
    fn test_user_age_valid(user in user_strategy()) {
        prop_assert!(user.age > 0 && user.age < 100);
    }

    #[test]
    fn test_user_email_format(user in user_strategy()) {
        prop_assert!(user.email.contains('@'));
        prop_assert!(user.email.ends_with(".com"));
    }
}

7.3 Fuzzing

Fuzzing automatically generates inputs to find crashes and bugs:

// Install: cargo install cargo-fuzz
// Create fuzzer: cargo fuzz init

// fuzz/fuzz_targets/fuzz_parser.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        // Fuzz your parser
        let _ = my_parser::parse(s);
    }
});

// Run: cargo fuzz run fuzz_parser

// Structured fuzzing with arbitrary
use arbitrary::Arbitrary;

#[derive(Debug, Arbitrary)]
struct FuzzInput {
    action: Action,
    value: i32,
}

#[derive(Debug, Arbitrary)]
enum Action {
    Add,
    Subtract,
    Multiply,
}

fuzz_target!(|input: FuzzInput| {
    match input.action {
        Action::Add => { /* test add */ }
        Action::Subtract => { /* test subtract */ }
        Action::Multiply => { /* test multiply */ }
    }
});

7.4 Mocking and Test Doubles

Manual Mocking

trait Database {
    fn get_user(&self, id: u64) -> Option<String>;
    fn save_user(&mut self, id: u64, name: String) -> Result<(), String>;
}

struct MockDatabase {
    users: std::collections::HashMap<u64, String>,
}

impl Database for MockDatabase {
    fn get_user(&self, id: u64) -> Option<String> {
        self.users.get(&id).cloned()
    }

    fn save_user(&mut self, id: u64, name: String) -> Result<(), String> {
        self.users.insert(id, name);
        Ok(())
    }
}

#[test]
fn test_user_service() {
    let mut db = MockDatabase {
        users: std::collections::HashMap::new(),
    };

    db.save_user(1, "Alice".to_string()).unwrap();
    assert_eq!(db.get_user(1), Some("Alice".to_string()));
}

Using mockall

use mockall::*;

#[automock]
trait Repository {
    fn find_by_id(&self, id: u64) -> Option<String>;
    fn save(&mut self, id: u64, data: String);
}

#[test]
fn test_with_mock() {
    let mut mock = MockRepository::new();

    // Set expectations
    mock.expect_find_by_id()
        .with(eq(1))
        .times(1)
        .returning(|_| Some("Alice".to_string()));

    mock.expect_save()
        .with(eq(2), eq("Bob".to_string()))
        .times(1)
        .returning(|_, _| ());

    // Use the mock
    assert_eq!(mock.find_by_id(1), Some("Alice".to_string()));
    mock.save(2, "Bob".to_string());
}

7.5 Snapshot Testing

use insta::assert_snapshot;

fn render_user(name: &str, age: u8) -> String {
    format!(
        r#"
        <div class="user">
            <h1>{}</h1>
            <p>Age: {}</p>
        </div>
        "#,
        name, age
    )
}

#[test]
fn test_render_user() {
    let output = render_user("Alice", 30);
    assert_snapshot!(output);
}

// First run creates snapshot
// Subsequent runs compare against it
// Review changes: cargo insta review

7.6 Integration Testing

Testing HTTP APIs

// tests/api_test.rs
use axum::{Router, routing::get};
use tower::ServiceExt;
use http::{Request, StatusCode};

async fn hello() -> &'static str {
    "Hello, World!"
}

fn app() -> Router {
    Router::new().route("/", get(hello))
}

#[tokio::test]
async fn test_hello_endpoint() {
    let app = app();

    let response = app
        .oneshot(Request::builder().uri("/").body(()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    let body = hyper::body::to_bytes(response.into_body())
        .await
        .unwrap();
    assert_eq!(&body[..], b"Hello, World!");
}

Database Testing

use sqlx::{PgPool, postgres::PgPoolOptions};

async fn setup_test_db() -> PgPool {
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://localhost/test_db")
        .await
        .unwrap();

    // Run migrations
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .unwrap();

    pool
}

async fn cleanup_test_db(pool: &PgPool) {
    sqlx::query("TRUNCATE TABLE users")
        .execute(pool)
        .await
        .unwrap();
}

#[tokio::test]
async fn test_create_user() {
    let pool = setup_test_db().await;

    sqlx::query("INSERT INTO users (name, email) VALUES ($1, $2)")
        .bind("Alice")
        .bind("alice@example.com")
        .execute(&pool)
        .await
        .unwrap();

    let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
        .fetch_one(&pool)
        .await
        .unwrap();

    assert_eq!(count.0, 1);

    cleanup_test_db(&pool).await;
}

7.7 Performance Testing

Benchmarking with Criterion

use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 | 1 => 1,
        n => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn criterion_benchmark(c: &mut Criterion) {
    let mut group = c.benchmark_group("fibonacci");

    for i in [10u64, 15, 20].iter() {
        group.bench_with_input(BenchmarkId::from_parameter(i), i, |b, &i| {
            b.iter(|| fibonacci(black_box(i)));
        });
    }

    group.finish();
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

Load Testing

use tokio::time::{sleep, Duration};
use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::test]
async fn load_test_api() {
    let semaphore = Arc::new(Semaphore::new(100)); // Max 100 concurrent
    let mut handles = vec![];

    for i in 0..1000 {
        let permit = semaphore.clone().acquire_owned().await.unwrap();
        let handle = tokio::spawn(async move {
            let _permit = permit; // Hold permit
            // Make request
            let response = make_api_request(i).await;
            assert!(response.is_ok());
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.await.unwrap();
    }
}

async fn make_api_request(id: u64) -> Result<(), String> {
    sleep(Duration::from_millis(10)).await;
    Ok(())
}

7.8 Test Organization

Test Fixtures

struct TestContext {
    db: MockDatabase,
    config: Config,
}

impl TestContext {
    fn new() -> Self {
        TestContext {
            db: MockDatabase::new(),
            config: Config::default(),
        }
    }

    fn with_user(mut self, id: u64, name: &str) -> Self {
        self.db.users.insert(id, name.to_string());
        self
    }
}

#[test]
fn test_with_fixture() {
    let ctx = TestContext::new()
        .with_user(1, "Alice")
        .with_user(2, "Bob");

    assert_eq!(ctx.db.get_user(1), Some("Alice".to_string()));
}

Test Helpers

mod test_helpers {
    pub fn create_test_user(name: &str) -> User {
        User {
            id: 1,
            name: name.to_string(),
            email: format!("{}@example.com", name.to_lowercase()),
        }
    }

    pub fn assert_user_eq(a: &User, b: &User) {
        assert_eq!(a.id, b.id);
        assert_eq!(a.name, b.name);
        assert_eq!(a.email, b.email);
    }
}

#[test]
fn test_user_creation() {
    let user = test_helpers::create_test_user("Alice");
    assert_eq!(user.name, "Alice");
}

7.9 Code Coverage

// Install tarpaulin
// cargo install cargo-tarpaulin

// Run coverage
// cargo tarpaulin --out Html --output-dir coverage

// Or use llvm-cov (built-in)
// rustup component add llvm-tools-preview
// cargo install cargo-llvm-cov

// Run coverage
// cargo llvm-cov --html

// Example: achieving high coverage
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_divide_success() {
        assert_eq!(divide(10, 2), Ok(5));
    }

    #[test]
    fn test_divide_by_zero() {
        assert_eq!(divide(10, 0), Err("Division by zero".to_string()));
    }

    // 100% coverage achieved!
}

7.10 Chapter Summary

Key Takeaways