Rust 错误处理与测试:thiserror、mockall 与属性测试

Rust 错误处理最佳实践:Result/Option 组合子、thiserror 与 anyhow 选型、panic 边界控制、单元/集成/文档测试、mockall 模拟、property-based testing 与代码覆盖率。

目录

  1. 错误处理哲学
  2. thiserror vs anyhow 选型
  3. Result 组合子
  4. Panic 边界与自定义 Hook
  5. 测试金字塔
  6. Mockall 模拟外部依赖
  7. Property-based Testing
  8. 代码覆盖率

1. 错误处理哲学

Rust 的错误处理哲学:可恢复错误用 Result,不可恢复错误用 panic。没有异常机制,所有错误路径必须显式处理。

// 可恢复错误:文件可能不存在
fn read_config(path: &str) -> Result<String, io::Error> {
    fs::read_to_string(path)
}

// 不可恢复错误:内部逻辑矛盾,应立刻终止
fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        panic!("division by zero"); // 程序员的 bug
    }
    a / b
}

2. thiserror vs anyhow 选型

crate用途适用场景
thiserror定义结构化错误类型库开发(API 暴露错误)
anyhow简化错误传播应用开发(快速处理错误)

2.1 thiserror:库级错误定义

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("Parse error at line {line}: {source}")]
    Parse {
        line: usize,
        #[source]
        source: serde_json::Error,
    },
    
    #[error("Validation failed: {0}")]
    Validation(String),
    
    #[error("Not found: {resource}")]
    NotFound { resource: String },
}

// 使用
fn load_data(path: &str) -> Result<Data, DataError> {
    let content = std::fs::read_to_string(path)?; // 自动 from io::Error
    let data: Data = serde_json::from_str(&content)
        .map_err(|e| DataError::Parse { line: 42, source: e })?;
    Ok(data)
}

2.2 anyhow:应用级快速错误

use anyhow::{Result, Context};

fn main() -> Result<()> {
    let config = std::fs::read_to_string("config.json")
        .with_context(|| "Failed to read config file")?;
    
    let data: Config = serde_json::from_str(&config)
        .context("Failed to parse config")?;
    
    process(&data)?;
    Ok(())
}

2.3 混合使用

// 库代码使用 thiserror
pub fn library_api() -> Result<Value, LibraryError> { ... }

// 应用代码使用 anyhow
fn main() -> anyhow::Result<()> {
    let v = my_crate::library_api().context("library call failed")?;
    Ok(())
}

3. Result 组合子

3.1 链路式处理

let user = fetch_user(id)
    .map_err(|e| Error::Database(e))?
    .ok_or(Error::UserNotFound)?
    .validate()?
    .into_dto();

3.2 map / and_then / or_else

let parsed: Result<i32, _> = "42".parse();
let doubled = parsed.map(|n| n * 2);           // Ok(84)
let or_zero = parsed.unwrap_or(0);              // 42

// 链式转换
let result = config
    .get("timeout")
    .and_then(|s| s.parse::<u64>().ok())
    .unwrap_or(30);

3.3 收集多个错误

// 使用 itertools 收集所有验证错误
let errors: Vec<_> = inputs.iter()
    .filter_map(|input| validate(input).err())
    .collect();

if !errors.is_empty() {
    return Err(Error::BatchValidation(errors));
}

4. Panic 边界与自定义 Hook

4.1 catch_unwind

use std::panic;

let result = panic::catch_unwind(|| {
    may_panic()
});

match result {
    Ok(val) => println!("Success: {}", val),
    Err(_) => println!("Task panicked"),
}

⚠️ catch_unwind 不捕获所有 panic(如 abort 模式),且不应作为常规错误处理。

4.2 自定义 Panic Hook

std::panic::set_hook(Box::new(|info| {
    log::error!("Panic occurred: {}", info);
    // 发送到 Sentry/Error Tracking
    sentry::capture_event(sentry::protocol::Event {
        message: Some(format!("{}", info)),
        level: sentry::Level::Fatal,
        ..Default::default()
    });
}));

4.3 不 panic 的 API 设计

// ❌ 容易 panic
pub fn get_unchecked(&self, index: usize) -> &T { &self.items[index] }

// ✅ 返回 Result
pub fn get(&self, index: usize) -> Result<&T, BoundsError> {
    self.items.get(index).ok_or(BoundsError { index })
}

// ✅ 或返回 Option
pub fn try_get(&self, index: usize) -> Option<&T> {
    self.items.get(index)
}

5. 测试金字塔

Rust 内置三种测试:

类型位置用途
单元测试模块内 #[cfg(test)]测试单个函数/模块
集成测试tests/ 目录测试公共 API 组合
文档测试/// 中的代码块确保文档示例可运行

5.1 单元测试

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

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_add() {
        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() {
        let _ = i32::MAX + 1; // debug 模式会 panic
    }
}

5.2 集成测试

// tests/api_test.rs
use my_crate::Client;

#[tokio::test]
async fn test_create_user() {
    let client = Client::new("http://localhost:3000");
    let user = client.create_user("Alice").await.unwrap();
    assert_eq!(user.name, "Alice");
}

5.3 文档测试

/// 计算两个数的最大公约数
/// ```
/// use my_crate::gcd;
/// assert_eq!(gcd(12, 8), 4);
/// ```
pub fn gcd(a: u64, b: u64) -> u64 {
    if b == 0 { a } else { gcd(b, a % b) }
}

5.4 异步测试

#[tokio::test]
async fn test_async_fetch() {
    let result = fetch_data().await.unwrap();
    assert!(!result.is_empty());
}

#[tokio::test]
async fn test_timeout() {
    let result = tokio::time::timeout(
        Duration::from_secs(1),
        slow_operation(),
    ).await;
    assert!(result.is_err());
}

6. Mockall 模拟外部依赖

6.1 生成 Mock

use mockall::automock;

#[automock]
pub trait Database {
    async fn get_user(&self, id: u64) -> Result<User, DbError>;
    async fn save_user(&self, user: &User) -> Result<(), DbError>;
}

6.2 使用 Mock

#[tokio::test]
async fn test_user_service() {
    let mut mock_db = MockDatabase::new();
    
    mock_db
        .expect_get_user()
        .with(eq(42))
        .times(1)
        .returning(|_| Ok(User { id: 42, name: "Alice".to_string() }));
    
    let service = UserService::new(mock_db);
    let user = service.find_user(42).await.unwrap();
    
    assert_eq!(user.name, "Alice");
}

6.3 序列与状态

mock_db
    .expect_save_user()
    .times(2)
    .returning(|_| Ok(()));

// 验证调用顺序
let mut seq = Sequence::new();
mock_db.expect_get_user()
    .times(1)
    .in_sequence(&mut seq)
    .returning(|_| Ok(User::default()));
mock_db.expect_save_user()
    .times(1)
    .in_sequence(&mut seq)
    .returning(|_| Ok(()));

7. Property-based Testing

使用 proptest 自动生成随机输入,发现边界情况。

use proptest::prelude::*;

proptest! {
    #[test]
    fn test_reverse_reverse(xs: Vec<i32>) {
        let mut rev = xs.clone();
        rev.reverse();
        rev.reverse();
        prop_assert_eq!(xs, rev);
    }
    
    #[test]
    fn test_add_commutative(a: i32, b: i32) {
        prop_assert_eq!(a + b, b + a);
    }
    
    #[test]
    fn test_sort_idempotent(mut xs: Vec<i32>) {
        xs.sort();
        let sorted_once = xs.clone();
        xs.sort();
        prop_assert_eq!(sorted_once, xs);
    }
}

失败时自动最小化反例:

测试失败,最小化后的反例:xs = [3, 1, 2]

8. 代码覆盖率

8.1 tarpaulin

cargo install cargo-tarpaulin

# HTML 报告
cargo tarpaulin --out Html

# 忽略测试代码
# 在 Cargo.toml 中添加
# [package.metadata.tarpaulin]
# exclude-files = ["tests/*"]

8.2 llvm-cov

cargo install cargo-llvm-cov

# 生成并查看覆盖率
cargo llvm-cov --html
open target/llvm-cov/html/index.html

# CI 集成:lcov 格式
cargo llvm-cov --lcov --output-path lcov.info

8.3 CI 集成示例

# .github/workflows/test.yml
- name: Run tests with coverage
  run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info

- name: Upload coverage
  uses: codecov/codecov-action@v3
  with:
    files: lcov.info

工具用途
thiserror结构化错误类型定义
anyhow应用级错误快速传播
mockall模拟 trait/外部依赖
proptest属性/随机测试
cargo-tarpaulin代码覆盖率分析
cargo-llvm-cov基于 LLVM 的覆盖率

Rust 的测试生态提供了从单元测试到覆盖率的全链路支持,配合编译期类型检查,可以在部署前捕获绝大多数 bug。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「rust」更多文章