本文介绍了基于属性的测试(PBT)的概念及其在 Rust 中的应用,通过示例展示了如何使用 proptest 库进行 PBT,并分享了在 cairo-rs 和 Patricia Merkle Tree 两个开源项目中使用 PBT 发现 bug 和验证代码正确性的实践案例。PBT 通过生成大量随机输入并检查代码的属性是否满足来有效地测试程序的正确性。
本文将探讨基于属性的测试,并演示其在我们两个开源项目中的应用。
首先,让我们解释一下什么是基于属性的测试(PBT):如果一张图片胜过千言万语,那么一个 PBT 就胜过一千个单元测试(尽管这是可调的,我们稍后会看到)。
它诞生于函数式编程社区,与传统方法截然不同。在测试程序正确性时,这是一个值得考虑的绝佳工具。
顾名思义,它基于测试代码的属性。换句话说,即我们期望在各种输入中保持一致的不变量或行为。当我们编写单元测试时,我们会针对一组特定的参数测试一个函数/方法。因此,我们通常使用具有代表性(但数量很少)的输入进行测试,我们认为代码可能会隐藏错误。相比之下,基于属性的测试会生成许多随机输入,并检查所有输入是否都满足该属性。如果它找到一个未满足的值,它会继续进行收缩过程,以找到打破该属性的最小输入。这样,更容易重现该问题。
说了这么多,让我们用一个简单的例子来展示它在实践中是如何工作的。我们将使用 Rust 来演示这种测试方式的好处。
Rust 中有几个用于进行基于属性的测试的库,但我们选择了 proptest,因为它使用起来很直接,并且正在积极维护。
在本例中,我们为将两个正数相加的函数创建一个测试。该测试检查正数加法的一个属性:结果大于每个单独的部分。我们使用 prop_assert! 宏来验证该属性是否成立。
use proptest::prelude::*;
fn add(a: i32, b: i32) -> i32 {
a + b
}
proptest! {
// Generate 1000 tests. // 生成 1000 个测试。
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn test_add(a in 0..1000i32, b in 0..1000i32) {
let sum = add(a, b);
prop_assert!(sum >= a);
prop_assert!(sum >= b);
prop_assert_eq!(a + b, sum);
}
}
让我们看看如果我们把第一个属性改成错误的会发生什么:
// prop_assert!(sum >= a); previous line // prop_assert!(sum >= a); 前一行
prop_assert!(sum <= a)
我们将收到一份报告,其中包含打破该属性的最小实例。
---- test_add stdout ----
thread 'test_add' panicked at 'Test failed: assertion failed: sum <= a at src/lib.rs:13; minimal failing input: a = 0, b = 1
successes: 0
local rejects: 0
global rejects: 0
', src/lib.rs:7:1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
为了为更复杂的结构构建测试,我们可以使用正则表达式(如果我们有一种从字符串构建数据类型的方法),或者使用 Strategies,它用于控制如何生成值以及如何完成收缩过程。
让我们从一个更实际的例子开始。在 LambdaClass,我们开发了一个 Cairo 虚拟机的 Rust 实现。Cairo 代表 CPU Algebraic Intermediate Representation(CPU 代数中间表示)。它是一种用于编写可证明程序的编程语言,其中一方可以向另一方证明计算已正确执行,方法是生成零知识证明。
执行用 Cairo 编写的程序涉及使用大量域元素(即,0 到一个巨大的素数之间的数字)进行运算。因此,每个运算(加法、减法、乘法和除法)都需要计算出一个范围在 [0; PRIME -1] 内的 felt(域元素)。
proptest! {
#[test]
// Property-based test that ensures, for 100 felt values that are randomly generated each time tests are run, that a new felt doesn't fall outside the range [0, PRIME-1].
// 基于属性的测试,确保对于每次运行测试时随机生成的 100 个 felt 值,新的 felt 不会超出范围 [0, PRIME-1]。
// In this and some of the following tests, The value of {x} can be either [0] or a huge number to try to overflow the value of {p} and thus ensure the modular arithmetic is working correctly.
// 在此和以下一些测试中,{x} 的值可以是 [0] 或一个很大的数字,以尝试溢出 {p} 的值,从而确保模运算正常工作。
fn new_in_range(ref x in "(0|[1-9][0-9]*)") {
let x = &Felt::parse_bytes(x.as_bytes(), 10).unwrap();
let p = &BigUint::parse_bytes(PRIME_STR[2..].as_bytes(), 16).unwrap();
prop_assert!(&x.to_biguint() < p);
}
#[test]
// Property-based test that ensures, for 100 felt values that are randomly generated each time tests are run, that the negative of a felt doesn't fall outside the range [0, PRIME-1].
// 基于属性的测试,确保对于每次运行测试时随机生成的 100 个 felt 值,felt 的负数不会超出范围 [0, PRIME-1]。
fn neg_in_range(ref x in "(0|[1-9][0-9]*)") {
let x = &Felt::parse_bytes(x.as_bytes(), 10).unwrap();
let neg = -x;
let as_uint = &neg.to_biguint();
let p = &BigUint::parse_bytes(PRIME_STR[2..].as_bytes(), 16).unwrap();
prop_assert!(as_uint < p);
}
#[test]
// Property-based test that ensures, for 100 {x} and {y} values that are randomly generated each time tests are run, that multiplication between two felts {x} and {y} and doesn't fall outside the range [0, PRIME-1]. The values of {x} and {y} can be either [0] or a very large number.
// 基于属性的测试,确保对于每次运行测试时随机生成的 100 个 {x} 和 {y} 值,两个 felts {x} 和 {y} 之间的乘法不会超出范围 [0, PRIME-1]。 {x} 和 {y} 的值可以是 [0] 或一个非常大的数字。
fn mul_in_range(ref x in "(0|[1-9][0-9]*)", ref y in "(0|[1-9][0-9]*)") {
let x = &Felt::parse_bytes(x.as_bytes(), 10).unwrap();
let y = &Felt::parse_bytes(y.as_bytes(), 10).unwrap();
let p = &BigUint::parse_bytes(PRIME_STR[2..].as_bytes(), 16).unwrap();
let prod = x * y;
let as_uint = &prod.to_biguint();
prop_assert!(as_uint < p, "{}", as_uint);
}
通过使用一套基于属性的测试来测试每个算术运算,我们已经发现了两个难以找到的错误。 此外,它还帮助我们轻松地将域元素的内部实现更改为性能更高的实现,并确信我们没有破坏任何东西。
在 LambdaClass,我们还在开发一个 Merkle Patricia 树库(类似于以太坊和许多其他与密码学相关的项目中使用的树)。为了测试实现的正确性,我们决定通过将库的操作结果与参考实现 cita-trie 的结果进行比较来进行基于属性的测试。
对于测试,让我们生成一些输入来创建两棵树:一棵使用参考实现,另一棵使用我们的库。
这一次,我们要测试的属性是,对于我们库中生成的每棵树,它的根哈希与参考实现的根哈希匹配。
fn proptest_compare_root_hashes(path in vec(any::<u8>(), 1..32), value in vec(any::<u8>(), 1..100)) {
use cita_trie::MemoryDB;
use cita_trie::{PatriciaTrie, Trie};
use hasher::HasherKeccak;
// Prepare the data for inserting it into the tree //准备数据以将其插入到树中
let data: Vec<(Vec<u8>, Vec<u8>)> = vec![(path, value)];
// Creates an empty patricia Merkle tree using our library and
// 使用我们的库创建一个空的 patricia Merkle 树
// Keccak256 as the hashing algorithm. // 并使用 Keccak256 作为哈希算法。
let mut tree = PatriciaMerkleTree::<_, _, Keccak256>::new();
// insert the data into the tree. // 将数据插入到树中。
for (key, val) in data.clone().into_iter() {
tree.insert(key, val);
}
// computes the root hash using our library // 使用我们的库计算根哈希
let root_hash = tree.compute_hash().as_slice().to_vec();
// Creates a cita-trie implementation of the // 创建 cita-trie Patricia Merkle 树的实现。
// Patricia Merkle tree. // Patricia Merkle 树。
let memdb = Arc::new(MemoryDB::new(true));
let hasher = Arc::new(HasherKeccak::new());
let mut trie = PatriciaTrie::new(Arc::clone(&memdb), Arc::clone(&hasher));
// Insert the data into the cita-trie tree. // 将数据插入到 cita-trie 树中。
for (key, value) in data {
trie.insert(key.to_vec(), value.to_vec()).unwrap();
}
// Calculates the cita-tree's root hash. // 计算 cita-tree 的根哈希。
let reference_root = trie.root().unwrap();
prop_assert_eq!(
reference_root,
root_hash
);
}
使用这种技术,我们可以确保我们的实现与参考实现的行为方式相同。
总之,基于属性的测试是一种强大而有效的测试程序正确性的方法。 测试属性有助于发现错误,并确保我们的程序在各种输入中满足不变量。 在本文中,我们演示了在两个开源项目中进行基于属性的测试。 我们希望你在测试实践中考虑它。
- 原文链接: blog.lambdaclass.com/wha...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!