42个版本 (7个稳定版)
1.5.0 | 2024年6月22日 |
---|---|
1.4.0 | 2023年11月10日 |
1.3.1 | 2023年10月1日 |
1.2.0 | 2023年5月23日 |
0.1.0 | 2017年6月18日 |
#2 in 测试
1,452,250 每月下载量
用于 1,663 个crate (1,335个直接使用)
1MB
15K SLoC
Proptest
书籍
有关Proptest的详细介绍可在书籍中找到
简介
Proptest是一个属性测试框架(即QuickCheck系列),灵感来自Hypothesis框架。它允许测试代码的某些属性对任意输入都成立,如果发现失败,会自动找到最小测试用例来重现问题。与QuickCheck不同,生成和收缩是在每个值的基础上定义的,而不是在每个类型的基础上,这使得它更加灵活,并简化了组合。
此crate的状态
该crate功能基本完善,已经有一段时间没有看到重大的架构变化。目前,它主要看到的是被动维护。
有关历史变化的完整列表(包括重大更改和非重大更改),请参阅更改日志。
MSRV
该crate当前的MSRV(最低稳定运行版本)为1.64。MSRV保证不会超过<当前稳定版本> - 7
,尽管在实际应用中可能更低——具体情况可能不同。如果我们以向后不兼容的方式改变此策略(例如,将其更改为<当前稳定版本> - 1
),这将构成一个重大更改,并可能导致版本号升级(例如,从1.1升级到2.0)。
什么是属性测试?
属性测试是一种通过检查代码的输出或行为在某些输入下是否满足特定属性来测试代码的系统。这些输入是自动生成的,并且,关键的是,当找到失败的输入时,该输入将自动减少到一个最小测试用例。
属性测试最适合用来补充传统的单元测试(即,使用手动选择的特定输入)。传统测试可以测试特定的已知边缘情况、简单输入以及在以前已知可以揭示错误的输入,而属性测试将搜索更复杂的输入,这些输入会导致问题。
入门指南
假设我们想要创建一个解析YYYY-MM-DD
格式的日期的功能。我们不会担心验证日期,任何三个整数的组合都可以。让我们快速实现一下。
fn parse_date(s: &str) -> Option<(u32, u32, u32)> {
if 10 != s.len() { return None; }
if "-" != &s[4..5] || "-" != &s[7..8] { return None; }
let year = &s[0..4];
let month = &s[6..7];
let day = &s[8..10];
year.parse::<u32>().ok().and_then(
|y| month.parse::<u32>().ok().and_then(
|m| day.parse::<u32>().ok().map(
|d| (y, m, d))))
}
编译通过了,这意味着它工作了吗?也许不是,让我们添加一些测试。
#[test]
fn test_parse_date() {
assert_eq!(None, parse_date("2017-06-1"));
assert_eq!(None, parse_date("2017-06-170"));
assert_eq!(None, parse_date("2017006-17"));
assert_eq!(None, parse_date("2017-06017"));
assert_eq!(Some((2017, 06, 17)), parse_date("2017-06-17"));
}
测试通过了,部署到生产环境!但现在应用程序开始崩溃,人们抱怨你把圣诞节移到了二月。我们可能需要更加彻底一些。
在Cargo.toml
中添加
[dev-dependencies]
proptest = "1.0.0"
现在我们可以为我们的日期解析器添加一些属性测试。但如何测试日期解析器对于任意输入,而无需在测试中创建另一个日期解析器来验证它?只要我们正确选择输入和属性,我们就不需要这样做。但在正确性之前,实际上有一个更简单的属性要测试:函数不应崩溃。让我们从这里开始。
// Bring the macros and other important things into scope.
use proptest::prelude::*;
proptest! {
#[test]
fn doesnt_crash(s in "\\PC*") {
parse_date(&s);
}
}
这样做就是取一个字面意义上的随机&String
(暂时忽略\\PC*
,我们稍后会回到这一点——如果你已经想出来了,请暂时忍住兴奋)并将其传递给parse_date()
,然后扔掉输出。
当我们运行这个程序时,我们会得到一些看起来很可怕的结果,最终以
thread 'main' panicked at 'Test failed: byte index 4 is not a char boundary; it is inside 'ௗ' (bytes 2..5) of `aAௗ0㌀0`; minimal failing input: s = "aAௗ0㌀0"
successes: 102
local rejects: 0
global rejects: 0
'
如果我们查看测试失败后的顶级目录,我们会看到一个名为proptest-regressions
的新目录,其中包含一些与包含失败测试用例的源文件相对应的文件。这些是失败持久化文件。我们应该做的第一件事是将这些文件添加到源代码控制中。
$ git add proptest-regressions
接下来,我们应该将失败的案例复制到一个传统的单元测试中,因为它暴露了一个与过去测试中不相似的错误。
#[test]
fn test_unicode_gibberish() {
assert_eq!(None, parse_date("aAௗ0㌀0"));
}
现在,让我们看看发生了什么...我们忘记了UTF-8!你不能盲目地切片字符串,因为你可能会分割一个字符,在这种情况下,这个字符串中的泰米尔语变音符号位于其他字符之上。
出于使代码更改尽可能小的目的,我们将检查字符串是否为ASCII,并拒绝任何不是ASCII的字符串。
fn parse_date(s: &str) -> Option<(u32, u32, u32)> {
if 10 != s.len() { return None; }
// NEW: Ignore non-ASCII strings so we don't need to deal with Unicode.
if !s.is_ascii() { return None; }
if "-" != &s[4..5] || "-" != &s[7..8] { return None; }
let year = &s[0..4];
let month = &s[6..7];
let day = &s[8..10];
year.parse::<u32>().ok().and_then(
|y| month.parse::<u32>().ok().and_then(
|m| day.parse::<u32>().ok().map(
|d| (y, m, d))))
}
现在测试通过了!但我们知道还有更多问题,所以让我们测试更多属性。
我们希望代码具有的另一个特性是解析每个有效的日期。我们可以在 proptest!
部分添加另一个测试。
proptest! {
// snip...
#[test]
fn parses_all_valid_dates(s in "[0-9]{4}-[0-9]{2}-[0-9]{2}") {
parse_date(&s).unwrap();
}
}
in
右侧实际上是一个 正则表达式,而 s
是从匹配它的字符串中选择出来的。因此,在我们的上一个测试中,"\\PC*"
正在生成由任意非控制字符组成的任意字符串。现在,我们以 YYYY-MM-DD 格式生成东西。
新的测试通过了,让我们继续做其他的事情。
我们想要检查的最后一个特性是日期确实被解析 正确。现在,我们不能通过生成字符串来做这件事——我们最终只是将日期解析器在测试中重新实现!相反,我们从预期的输出开始,生成字符串,并检查它是否被解析回。
proptest! {
// snip...
#[test]
fn parses_date_back_to_original(y in 0u32..10000,
m in 1u32..13, d in 1u32..32) {
let (y2, m2, d2) = parse_date(
&format!("{:04}-{:02}-{:02}", y, m, d)).unwrap();
// prop_assert_eq! is basically the same as assert_eq!, but doesn't
// cause a bunch of panic messages to be printed on intermediate
// test failures. Which one to use is largely a matter of taste.
prop_assert_eq!((y, m, d), (y2, m2, d2));
}
}
在这里,我们看到除了正则表达式之外,我们还可以使用任何表达式,它是 proptest::strategy::Strategy
,在这种情况下,是整数范围。
当我们运行测试时,测试失败了。尽管这次输出不多。
thread 'main' panicked at 'Test failed: assertion failed: `(left == right)` (left: `(0, 10, 1)`, right: `(0, 0, 1)`) at examples/dateparser_v2.rs:46; minimal failing input: y = 0, m = 10, d = 1
successes: 2
local rejects: 0
global rejects: 0
', examples/dateparser_v2.rs:33
note: Run with `RUST_BACKTRACE=1` for a backtrace.
失败的输入是 (y, m, d) = (0, 10, 1)
,这是一个相当具体的输出。在考虑为什么这会破坏代码之前,让我们看看 proptest 是如何到达这个值的。在我们的测试函数开始时插入
println!("y = {}, m = {}, d = {}", y, m, d);
再次运行测试,我们得到类似这样的结果
y = 2497, m = 8, d = 27
y = 9641, m = 8, d = 18
y = 7360, m = 12, d = 20
y = 3680, m = 12, d = 20
y = 1840, m = 12, d = 20
y = 920, m = 12, d = 20
y = 460, m = 12, d = 20
y = 230, m = 12, d = 20
y = 115, m = 12, d = 20
y = 57, m = 12, d = 20
y = 28, m = 12, d = 20
y = 14, m = 12, d = 20
y = 7, m = 12, d = 20
y = 3, m = 12, d = 20
y = 1, m = 12, d = 20
y = 0, m = 12, d = 20
y = 0, m = 6, d = 20
y = 0, m = 9, d = 20
y = 0, m = 11, d = 20
y = 0, m = 10, d = 20
y = 0, m = 10, d = 10
y = 0, m = 10, d = 5
y = 0, m = 10, d = 3
y = 0, m = 10, d = 2
y = 0, m = 10, d = 1
测试失败的消息说有两个成功的案例;我们在顶部看到这些,2497-08-27
和 9641-08-18
。下一个案例,7360-12-20
,失败了。这个日期并没有立即明显的特殊之处。幸运的是,proptest 将它简化为一个更简单的案例。首先,它迅速将 y
输入减少到 0
,并同样将 d
输入减少到最小允许值 1
。在这两者之间,我们看到了不同:它试图将 12
缩小到 6
,但最终又将其提升到 10
。这是因为 0000-06-20
和 0000-09-20
测试案例 通过 了。
最终,我们得到了日期 0000-10-01
,它显然被解析为 0000-00-01
。同样,这个失败的案例被添加到失败持久化文件中,我们应该将其作为一个单独的单元测试添加
$ git add proptest-regressions
#[test]
fn test_october_first() {
assert_eq!(Some((0, 10, 1)), parse_date("0000-10-01"));
}
现在要找出代码中哪里出了问题。即使没有中间输入,我们也可以有相当大的信心地说,年份和日期部分没有进入画面,因为它们都被减少到最小可接受输入。月份输入 没有,但被减少到 10
。这意味着我们可以推断出 10
有一些特殊之处,这种特殊之处不适用于 9
。在这种情况下,“特殊之处”是该数字为两位数。在我们的代码中
let month = &s[6..7];
我们差了一步,需要使用范围 5..7
。修复这个问题后,测试通过。
proptest!
宏还有一些额外的语法,包括设置生成测试用例数量的配置。有关更多详细信息,请参阅其 文档。
QuickCheck 和 Proptest 之间的差异
QuickCheck 和 Proptest 在许多方面相似:两者都为函数生成随机输入以检查某些属性,并自动将输入缩小到最小失败案例。
一个很大的不同之处在于,QuickCheck 基于类型本身生成和缩小值,而 Proptest 使用显式的 Strategy
对象。与 QuickCheck 相比,这种方法有很多缺点。
-
QuickCheck 只能为每种类型定义一个生成器和缩小器。如果您需要自定义生成策略,则需要将其包装在一个新类型中并手动实现特质。在 Proptest 中,您可以为同一类型定义任意多个不同的策略,并且有很多内置策略。
-
出于同样的原因,QuickCheck 只有一个“大小”配置,试图定义生成值的范围。如果您需要一个介于 0 到 100 之间的整数和一个介于 0 到 1000 之间的整数,您可能需要使用另一个新类型。在 Proptest 中,您可以直接表示您想要一个
0..100
整数和一个0..1000
整数。 -
QuickCheck 中的类型不容易组合。为简单由其字段组合而成的新结构体定义
Arbitrary
和Shrink
需要手动实现,包括在结构体和其字段元组的双向映射。在 Proptest 中,您可以创建所需组件的元组,然后使用prop_map
将其映射到所需的格式。缩小在输入类型方面是自动发生的。 -
由于 QuickCheck 中无法表达值约束,生成和缩小可能会导致许多输入被拒绝。Proptest 中的策略知道简单的约束,并且不会生成或缩小违反它们的值。
Hypothesis 的作者还有一篇关于这个主题的 文章。
当然,也有一些相对的缺点,这些缺点源于 Proptest 的不同之处。
- 在 Proptest 中生成复杂值可能比 QuickCheck 慢一个数量级。这是因为 QuickCheck 基于输出值执行无状态的缩小,而 Proptest 必须保留所有中间状态和关系,以便其更丰富的缩小模型能够工作。
属性测试的局限性
在无限时间内,属性测试最终将探索测试的所有输入空间。然而,时间不是无限的,因此只能探索输入空间的随机样本部分。这意味着在大型空间中,属性测试几乎不可能找到单值边缘情况。例如,以下测试几乎总是通过
use proptest::prelude::*;
proptest! {
#[test]
fn i64_abs_is_never_negative(a: i64) {
// This actually fails if a == i64::MIN, but randomly picking one
// specific value out of 2⁶⁴ is overwhelmingly unlikely.
assert!(a.abs() >= 0);
}
}
因此,对于许多类型的问题,仍然需要使用智能选择的案例进行传统单元测试。
同样,在某些情况下,可能很难或无法定义一个实际产生有用输入的策略。例如,策略 .{1,4096}
可能非常适合模糊测试 C 解析器,但几乎不可能产生任何能够到达代码生成器的结果。
致谢
如果没有 QuickCheck 的 Rust 端口 和 regex_generate
提供的精彩示例,这个包可能就不会存在。
贡献
除非你明确表示,否则任何有意提交以包含在您的工作中的贡献,根据 Apache-2.0 许可证定义,应以上述方式双许可,没有任何附加条款或条件。
依赖项
~2–12MB
~143K SLoC