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

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

目录

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

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(())
}

2.5 何时使用 panic 而非 Result

Rust 社区有一个经典原则:panic 用于不可能发生的 bug,Result 用于可能发生的错误。但在实际编码中,这条界线有时模糊不清。

应当 panic 的场景:

场景说明示例
内部状态不一致程序员的逻辑错误,正常流程不应触发数组索引越界(若已做前置检查)
前置条件违反API 调用方未按契约使用PoisonError(锁中毒表示内部 bug)
类型系统无法表达的不变量编译器无法静态保证模式匹配遗漏(unreachable!())

应当返回 Result 的场景:

场景说明示例
外部系统故障网络、磁盘、数据库都可能失败文件读写、HTTP 请求
输入数据无效用户输入、API 参数JSON 解析、表单验证
资源不足内存、句柄可能耗尽线程创建、连接池获取
// ✅ 外部错误用 Result
pub fn read_file(path: &Path) -> Result<String, io::Error> {
    fs::read_to_string(path)
}

// ✅ 内部不一致用 panic(已做前置校验不应触发)
pub fn get_first_element(arr: &[i32]) -> i32 {
    // 调用方已检查非空的前提下
    *arr.first().expect("BUG: empty array passed after length check")
}

// ❌ 滥用 panic
pub fn parse_port(s: &str) -> u16 {
    s.parse().expect("Invalid port") // 用户输入不应 panic!
}

// ✅ 正确做法
pub fn parse_port(s: &str) -> Result<u16, ParseError> {
    s.parse().map_err(|_| ParseError::InvalidPort(s.to_string()))
}

最佳实践:库代码绝不 panic(让用户决定),仅在 debug_assert! 中使用 panic;应用代码在启动阶段可以 panic(配置缺失直接退出),运行时一律用 Result。使用 unwrap() 前问自己:“这个值为 Err 或 None 是否意味着程序有 bug?”

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 的覆盖率

9. 错误处理设计模式

生产级 Rust 应用不仅需要正确的错误类型,还需要合理的错误处理策略,如重试、退避和断路器。

9.1 指数退避重试

use tokio::time::{sleep, Duration};

async fn retry_with_backoff<F, Fut, T, E>(
    mut f: F,
    max_retries: u32,
) -> Result<T, E>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T, E>>,
    E: std::fmt::Debug,
{
    let mut attempts = 0;
    loop {
        match f().await {
            Ok(val) => return Ok(val),
            Err(e) if attempts < max_retries => {
                attempts += 1;
                let delay = Duration::from_millis(100 * 2_u64.pow(attempts));
                eprintln!("Attempt {} failed: {:?}, retrying in {:?}", attempts, e, delay);
                sleep(delay).await;
            }
            Err(e) => return Err(e),
        }
    }
}

// 使用
let result = retry_with_backoff(
    || fetch_data_from_api(),
    5,
).await?;

9.2 断路器模式(Circuit Breaker)

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;

#[derive(Clone)]
pub struct CircuitBreaker {
    failure_threshold: u32,
    success_threshold: u32,
    timeout: Duration,
    state: Arc<AtomicU32>,  // 0=Closed, 1=Open, 2=HalfOpen
    failures: Arc<AtomicU32>,
}

impl CircuitBreaker {
    pub async fn call<F, Fut, T>(&self, f: F) -> Result<T, CircuitBreakerError>
    where
        F: FnOnce() -> Fut,
        Fut: std::future::Future<Output = Result<T, Box<dyn std::error::Error>>>,
    {
        if self.state.load(Ordering::SeqCst) == 1 {
            return Err(CircuitBreakerError::Open);
        }

        match f().await {
            Ok(val) => {
                self.failures.store(0, Ordering::SeqCst);
                if self.state.load(Ordering::SeqCst) == 2 {
                    self.state.store(0, Ordering::SeqCst); // HalfOpen → Closed
                }
                Ok(val)
            }
            Err(_) => {
                let fails = self.failures.fetch_add(1, Ordering::SeqCst) + 1;
                if fails >= self.failure_threshold {
                    self.state.store(1, Ordering::SeqCst); // Open
                }
                Err(CircuitBreakerError::Failure)
            }
        }
    }
}

设计原则:库代码返回具体错误(thiserror),应用代码决定错误处理策略(anyhow + 重试/断路器)。永远不要在库中悄悄吞掉错误,也不要让应用层被迫处理每个底层错误细节。

延伸阅读

  • https://plumephp.com/rust-desktop-wasm/ — Rust WebAssembly 与桌面开发
  • https://plumephp.com/rust-systems-programming/ — Rust 系统级编程
  • https://plumephp.com/python-typing-pydantic/ — 对比 Pydantic 与 Rust 类型系统
  • https://plumephp.com/event-driven-architecture-domain-events/ — 异步事件处理中的错误传播

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

继续阅读

探索更多技术文章

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

全部文章 返回首页

「rust」更多文章

  1. 性能剖析与优化
  2. 测试与基准:criterion 与 proptest
  3. Serde 与序列化生态