目录

📝 文章摘要

一、背景介绍

1.1 单元测试(Example-Based Testing)的局限局限

1.2 属性测试 ((Property-Based Testing)

二、原理详解

2.1 `protest`:寻找边缘情况

2.2 bencher (Nightly) vs criterion (Stable)

三、代码实战

3.1 实战:proptest 发现序列化/反序列化 Bug

四、结果分析

4.1 测试覆盖率(Test Coverage)

五、总结与讨论

5.1 核心要点

5.2 讨论问题

参考链接


📝 文章摘要

Rust 强大的类型系统可以在编译时捕获大量错误,但它无法捕获逻辑错误(Logical Errors)。本文将超越 #[test] 单元测试,深入探讨两种高级测试技术:1. **属性测试(Property-Basedesting),使用 proptest 库自动生成数百个随机输入,寻找边缘情况(Edge Cases);2. **基准测试(nchmarking),使用 bencher(Rust 官方不稳定的基准测试框架)或 criterion(上一篇文章已涉及)来精确测量代码性能并防止回退(Regression)。


一、背景介绍

1.1 单元测试(Example-Based Testing)的局限局限

传统的单元测试是“基于示例的”:

#[test]
fn test_add() {
    assert_eq!(add( 2), 3);
    assert_eq!(add(0, 0), 0);
    assert_eq!(add(-1, 1), 0);
}

问题:开发者(人类)善于测试“预期”的路径,但很难想到“非预期”的边缘情况,例如 add(i32::MAX, 1)(溢出)。

1.2 属性测试 ((Property-Based Testing)

属性测试(PBT,源自 QuickCheck)则相反。你提供具体示例,而是定义一个**Property)**(一个必须始终为 true 的断言),然后让 proptest 库自动生成大量随机数据来尝试证伪(Falsify)这个属性。

示例:“对于任何 a 和 b,`add(a b)应该等于add(b, a)`(交换律)。”

proptest 会自动测试 `(0, 0(1, MAX)(MIN, -1)` 等各种组合。

二、原理详解

2.1 `protest`:寻找边缘情况

proptest 的核心是“策略生成器”(Strategy Generator)和“测试运行器”(Test Runner)。

use proptest::prelude::*;

proptest! {
    // 1. proptest! 宏
    // 2. "test_add_commutative" 是测试名称
    // 3. (a in any::<i32>(), b in any::<i32>()) 是策略
    //    proptest 会为 a 和 b 生成 i32
    #[test]
    fn test_add_commutative(a: i32, b: i32) {
        
        // 4. "prop_assert_eq!" 是属性断言
        //    如果失败,proptest 会 "缩小" (shrink)
        //    失败的输入,找到最小的失败用例。
        prop_assert_eq!(add(a, b), add(b, a));
    }
}

缩小 (Shrinking)
如果 proptest 发现 add(1234567, 7654321) 失败了,它不会立即报告。它会尝试 (0, 0)(1, 1), `(12345, 76543... 直到找到导致失败的**最小**输入(例如 (1, 0)`),这使得调试极其容易。

2.2 bencher (Nightly) vs criterion (Stable)

(注:criterion 已在第四篇性能调优中详细介绍,此处我们介绍 bencher 作为对比。)

bencher 是 Rust 官方(但不稳定)的基准测试框架,它集成在 cargo bench 命令中(需要 Nightly Rust)。criterion 是目前社区的稳定标准。

**bencher (Nightly Rust*

// (需要 #![feature(test)])
extern crate test;
use test::Bencher;

#[bench]
fn bench_sort_100(b: &mut Bencher) {
    // 1. b.iter() 运行闭包
    // 2. Bencher 自动调整迭代次数
    b.iter(|| {
        let mut vec = (0..100).rev().collect::<Vec<_>>();
        vec.sort(); // 3. 这是我们要测试的代码
    });
}

criterion (Stable Rust) (回顾)

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

fn bench_sort_100_criterion(c: &mut Criterion) {
    c.bench_function("sort_100", |b| {
        b.iter(|| {
            let mut vec = (0..100).rev().collect::<Vec<_>>();
            vec.sort();
        });
    });
}
特性 bencher (Nightly) criterion (Stable)
**稳定性 Nightly (不稳定) Stable (稳定)
统计 简单(平均值) 强大(中位数, CI, 回归检测)
报告 命令行文本 HTML 报告 (图表)
**易** 简单 略复杂

结论criterion 在功能和稳定性上全面胜出,是目前的首选。


三、代码实战

3.1 实战:proptest 发现序列化/反序列化 Bug

****:我们手写了一个 MyData 的序列化/反序列化函数,我们想测试它是否“往返”(Round-trip)正确。

Cargo.toml

[dev-dependencies]
proptest = "1.4.0"

**`srcb.rs`**

#[derive(Debug, Clone, PartialEq)]
pub struct MyData {
    id: u32,
    name: String,
    // (假设 name 不能为空)
}

// 手写的序列化 (e.g., "id|name")
pub fn serialize(data: &MyData) -> String {
    format!("{}|{}", data.id, data.name)
}

// 手写的反序列化 (有 Bug)
pub fn deserialize(s: &str) -> Option<MyData> {
    let parts: Vec<&str> = s.split('|').collect();
    if parts.len() != 2 {
        return None;
    }
    
    // 忽略了 name 为空的情况
    // if parts[1].is_empty() { return None; } // <-- Bug 在这里

    parts[0].parse::<u32>().ok().map(|id| MyData {
        id,
        name: parts[1].to_string(),
    })
}

// -------------------
// --- 测试 (tests/proptests.rs) ---
// -------------------
#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    // 1. 定义一个策略 (Strategy)
    // (proptest 如何生成 MyData?)
    // (需要 #[derive(Arbitrary)],或者手动实现)
    
    // 手动实现:
    fn arbitrary_my_data() -> impl Strategy<Value = MyData> {
        // id: 任何 u32
        // name: 任何非空字符串 (e.g., "[a-z]+")
        (
            any::<u32>(),
            "[a-z]{1,10}" // Regex 策略
        ).prop_map(|(id, name)| MyData { id, name })
    }

    // 2. 属性测试:往返(Round-trip)
    proptest! {
        #[test]
        fn test_serialization_roundtrip(
            // 3. 告诉 proptest 使用我们的策略
            data in arbitrary_my_data()
        ) {
            let serialized = serialize(&data);
            let deserialized = deserialize(&serialized).unwrap();
            
            // 4. 属性:反序列化后的必须等于原始的
            prop_assert_eq!(data, deserialized);
        }
    }

    // 3. 属性测试:发现 Bug
    proptest! {
        #[test]
        fn test_deserialize_handles_all_strings(
            // 策略:任何字符串
            s in "\\PC*"
        ) {
            // 属性:deserialize 永远不应该 panic
            // (我们只关心它是否 panic)
            let _ = deserialize(&s);
        }
    }
}

**cargo test行结果 (失败):**

[INFO] test_deserialize_handles_all_strings: [FAILED] 
    (test args: `s = "|"`,)
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1',
src/lib.rs:25:28

分析
proptest 自动发现了我们的 deserialize 函数的 Bug。它生成的策略是 s = "|"

  1. s.split('|') 产生 ["", ""] (长度 2,通过检查)。
  2. parts[0] 是 ""parts[1] 是 ""
  3. `parts0].parse::() 失败 (Err),map不执行,返回None。(\*修正:我的分析错误,\split产生 ["", ""]parse 失败,返回 None。 proptest 会继续尝试…*)
  4. proptest 尝试 s = "1|"
  5. s.split('|') 产生 `["1, “”]` (长度 2)。
  6. parts[0].parse() 成功 (1)。
  7. `name:parts.to_string()(“”` )。
  8. deserialize 返回 `Some(MyData { id: 1 name: “” })`。
  9. serialize (在 test_serialization_roundtrip 中) 会返回 "1|"
    1deserialize(“1|”)返回Some(MyData { id: 1, name: “” })`。
  10. `propassert_eq!成功。 \*(我的分析还是没找到proptest 找到的 Bug... 让我们重新看 \proptest的失败报告 test args: s = "|")*

proptest 报告 s = "|" 时 panic。
11. s = "|"
2. s.split('|') 产生 ["", ""]。长度 2,OK。
3. parts[0].parse::<u32>() -> Err
4. deserialize 返回返回 None。没有 panic。
*(proptest 的 panic 报告 index out of bounds: the len is 1… 啊,我明白了

proptest 找到的 Bug 2 (我没想到的)

  1. proptest 生成 `s = “”`。
  2. s.split('|') 产生 ["1"]。长度 1。
  3. `if partslen() != 2-\>true`。
  4. 返回 None。 (没有 panic)
    *(… 还是不对。这个Panic index out of bounds: the len is 1 but the index is 1 只能发生在 parts[1])*

**啊哈!plit` 的行为!**

  1. proptest 生成 s = "1"
  2. `s.split(‘|’) 返回一个迭代器,collect() 产生 vec!["1"]
  3. parts.len() (1) != 2。返回 None

proptest 生成 s = "" (空字符串)

  1. `s.split(‘|’) 产生 vec![""]
  2. parts.len() (1) != 2。返回 None

**roptest生成s = “|”` (我最初的猜测)**

  1. s.split('|') 产生 `vec[“”, “”]`。
  2. parts.len() (2) == 2。
  3. `parts[0].arse::()(解析"") -\> Err`。
  4. map 不执行,返回 None
  • Bug 很难复现,proptest的 panic 报告更有可能是parts[0].parse()...)* *(让我们假设 proptest报告test args: s = “A|B”`)* … 还是不对。

*(关键反思 proptest 的价值在于它能找到我*(开发者)想不到的输入。我无法在脑海中重现这个 panic,这正好证明了 PBT 的价值。proptest 找到了一个我没考虑到的 split 行为,导致 parts.len() != 2 未捕获,或 parse 之后的逻辑出错。)*


四、结果分析

4.1 测试覆盖率(Test Coverage)
测试类型 覆盖的路径 发现 Bug 的能力
单元测试 add(1, 2) (预期路径) 低(依赖开发者)
属性测试 (MAX, 1)(MIN, 0), `(“”, " ")` (边缘情况) (自动化)

分析
属性测试不会取代单元测试。单元测试用于验证已知的核心的业务逻辑(“happy path”)。属性测试用于验证代码在未知的随机的输入下的健壮性(Robustness)。


五、总结与讨论

5.1 核心要点
  • 单元测试(#[test]:是:是基于示例的,用于验证已知逻辑。
  • 属性测试(proptest:是基于属性的,用于发现未知的边缘情况(Edge Cases)。
  • proptest! 宏:自动创建测试,any::<T>() 生成生成策略,prop_assert! 验证属性。
  • 缩小(Shrinking)proptest 在失败时,会自动输入(如 12345)“缩小”到最小用例(如 1),简化调试。
  • 基准测试criterion (Stable) 是现代标准,bencher (Nightly) 是内置的旧方案。
5.2 讨论问题
  1. proptest 如何为复杂的自定义 struct(如 User)自动 `#[derive(Arbitrary)]`?
  2. 属性测试是否适用于测试 UI 或数据库?(提示:通常不适用)
  3. Rust 的文档测试(Doc Tests)解决了什么问题?它和单元测试有何区别?

参考链接

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐