QuickCheck 教程 Part 1

这是一个长系列,目标是系统介绍 MoonBit 中的 QuickCheck 框架及其在工程实践中的应用, 向广大开发者介绍「基于属性的测试设计」理念与方法论。 整个系列将分为 3 个大部分,第一部分,也就是本文, 关注 QuickCheck 的核心概念和『属性』的设计方法论, 后面部分将介绍生成器的高级设计与缩减策略和统计分布控制等技巧。
基本概念解释
本章旨在建立我们对「性质测试」的直观理解,我们不急着讨论复杂生成器或者属性, 而是从最直观的角度确认 QuickCheck 在 MoonBit 中到底验证了什么。 这里的性质 (property) 不是一组样例,而是一段描述规则的程序, 它会被反复运行在大量随机数据上(在程序上可以体现为一个函数或者一个可执行的表达式), 从而让我们以更低成本覆盖更大行为空间。
当我们把一个性质交给 QuickCheck 时,框架需要先判断它是不是可执行的,为此 QuickCheck 提供了 Testable 这一抽象,它能把 Bool、Function、甚至 Generator 统一成可运行的 Property。换句话说,我们写的不一定是 「测试」,而是一个能被转译成测试流程的值,这个值会被运行、统计、收集并最终给出结论。
fn[P : @qc.Testable] @qc.quick_check(prop : P, max_success? : Int, ...) -> Unit raise Failure
上面的函数签名展示了 QuickCheck 的核心入口 @qc.quick_check。它接受一个 Testable 值,将其转成 Property 并运行测试。max_success 参数控制测试样例数量,默认值为 100。若性质在所有样例上成立,测试通过;否则抛出 Failure 并打印反例,当然它不止 max_success 这个可配置参数,之后的章节中,我们将按需引入不同的配置项。
///|
test "@qc.quick_check minimal" {
@qc.quick_check(true)
}
这个最小示例几乎没有任何信息,却能帮助我们准确把握接口形态。
@qc.quick_check 接受任何 Testable 值,
因此布尔值也可以直接作为性质 (因为它实现了这个 trait),
若为 false 就会失败。这样的调用虽然极简,但清晰揭示了
QuickCheck 的入口语义,即它只关心「这段可执行的性质最终是否成立」。
当性质是一个函数时,最便捷的入口是 @qc.quick_check_fn。
它要求函数的参数类型具备 Arbitrary、Shrink 与 Show 能力,
从而能自动生成测试数据、缩减反例并打印失败样本,在后面我们会更多解释这些 trait 的含义,
但现在你只需要知道对于标准库的基础类型,QuickCheck 都实现了这些 trait。
我们可以把它理解为「我们提供规则,系统替我们提供数据」的测试模式,在简单模型上非常高效。
下面的例子也很简单,验证了「整数加零不变」的性质,生成器会从小到大生成 100 个整数样例并运行该性质,
并检查结果是否全部为真,如果是,则测试通过,否则打 印出第一个失败的样例。
///|
fn prop_add_zero(x : Int) -> Bool {
x + 0 == x
}
///|
test "@qc.quick_check_fn" {
@qc.quick_check_fn(prop_add_zero)
}
实际业务函数往往是多参的,而 property 又只能接收一个参数,因此我们用元组把多个输入打包起来。 这不是权宜之计,而是语言层面的标准做法,它让 QuickCheck 仍然保持「单参性质」的统一执行流程, 同时也便于缩减时生成更小的反例组合。
///|
fn prop_add_comm(pair : (Int, Int)) -> Bool {
let (a, b) = pair
a + b == b + a
}
///|
test "@qc.tuple property" {
@qc.quick_check_fn(prop_add_comm)
}
当我们需要主动决定数据分布时,或者 Arbitrary 实例并不适用时,
就需要显式生成器,这个入口是 @qc.forall。@qc.forall 的含义是「对生成器给出的每一个值,
都让性质成立」,它把生成器与性质函数粘合成 Property,是后续复杂设计的基础。
在这里我们先给出一个小范围的整数生成器,让性质更容易直观地理解。
///|
test "@qc.forall with @qc.int_range" {
let gen = @qc.int_range(-10, 10)
let prop = @qc.forall(gen, fn(x) { x + 1 > x })
@qc.quick_check(prop)
}
生成器并非神秘黑箱,它是一个由 size 与随机种子驱动的确定性函数。
虽然 @qc.quick_check 会帮我们自动管理这些参数,但在设计性质时,
我们仍然可以用 @qc.Gen::sample 先窥视生成器的行为,帮助我们校准
数据分布是否符合预期。
///|
test "peek generator" {
let gen = @qc.int_range(-3, 3)
inspect(gen.sample(size=5, seed=1), content="-2")
}
在这里我们可以讨论一些 QuickCheck 内部的结构细节:
从执行流程上看,Testable 会被转成 Property,Property 内部再被展开成可遍历的测试树,QuickCheck 沿着这棵树运行、记录、缩减并最终决定结果。我们暂时不需要理解这些结构细节,但要清楚性质是「可执行」 的对象,而不是静态文档,这一层理解会决定我们之后如何组织属性与生成器。
当然读者无需担心这些细节会妨碍我们使用 QuickCheck,框架已经帮我们封装好了这些复杂性, 我们只需专注于「写性质」与「选生成器」即可。
失败的处理也是接口语义的一部分,@qc.quick_check 会在性质失败时抛出 Failure 并打印反例,而
@qc.quick_check_silence 则返回一段报告字符串,方便我们在工具链中做二次处理。理解这一点有助于我们在
调试和持续集成中选择合适的入口。
///|
test "@qc.quick_check_silence" {
let prop = @qc.forall(@qc.int_range(0, 5), fn(x) { x >= 0 })
inspect(@qc.quick_check_silence(prop), content="+++ [100/0/100] Ok, passed!")
}
到这里我们已经能把一个直观规则写成可运行的性质,并通过默认生成器或显式生成器运行它。我们会发现 QuickCheck 并不要求我们先理解复杂的缩减细节,而是提供了一条从「规则」到「执行」的清晰通路, 这也是 property-based testing 真正降低测试成本的原因。
Property 设计导论
本章关注如何从需求/代码中抽取可验证的性质,我们不急于讨论复杂生成器, 而是把注意力放在「关系」与「不变量」上。 需求常以自然语言或数学公司表达,它蕴含了不变量与代数规律, 我们要做的就是把这些规律转化为可执行的 property, 并让随机测试去检验它们是否稳定成立。
代数性质
在性质测试中,最可靠的起点是「关系式」,它描述输入与输出之间应当长期成立的约束。相比样例断言, 关系式具有普适性,能够覆盖更大范围的输入组合。我们可以把「应该相等」、「应当保持顺序」或 「重复应用后不再变化」这样的语义转化为函数层面的规律,并将其交给 QuickCheck 执行。
QuickCheck 收集了常见的代数规律作为内置性质,这些规律直接对应了需求中常见的模式。例如,
当需求暗含交换性时,我们可以直接采用 @qc.commutative 来表达等式关系。这里我们用整数加法作为示例,
并限制输入范围以避免溢出干扰性质本身。这样的范围设置不是削弱测试,而是帮助我们聚焦在需求语义上。
///|
test "@qc.commutative for add" {
let gen = @qc.tuple(@qc.int_range(-200, 200), @qc.int_range(-200, 200))
let prop = @qc.forall(gen, @qc.commutative(fn(a, b) { a + b }))
@qc.quick_check(prop)
}
需求中常见的「合并不依赖分组方式」可以用结合律表达,这类规律尤其适用于聚合、拼接、合并等函数。
我们借助 @qc.associative 直接表达结合律,并用三元组生成器将多参输入统一为单参性质。
///|
test "@qc.associative for add" {
let gen = @qc.triple(
@qc.int_range(-20, 20),
@qc.int_range(-20, 20),
@qc.int_range(-20, 20),
)
let prop = @qc.forall(gen, @qc.associative(Int::add))
@qc.quick_check(prop)
}
当需求包含「分配」的语义时,我们通常需要把两个运算的关系固定下来。分配律不仅揭示了运算组合的结构, 也能快速检验实现是否正确地遵守数学规则。我们在这里选用乘法对加法的左分配律来表达这一类需求。
///|
test "@qc.distributive_left for mul/add" {
let gen = @qc.triple(
@qc.int_range(-12, 12),
@qc.int_range(-12, 12),
@qc.int_range(-12, 12),
)
let prop = @qc.forall(gen, @qc.distributive_left(Int::mul, Int::add))
@qc.quick_check(prop)
}
除了代数律,很多业务需求本质上是「重复应用不会继续改变结果」。这类需求适合用幂等性刻画,
如归一化、去噪、裁剪等操作。我们可以先写出一个简单的非负裁剪函数,再用 @qc.idempotent 验证其性质。
///|
fn clamp_nonneg(x : Int) -> Int {
guard x < 0 else { x }
0
}
///|
test "@qc.idempotent clamp" {
let prop = @qc.forall(@qc.int_range(-50, 50), @qc.idempotent(clamp_nonneg))
@qc.quick_check(prop)
}
另一个常见模式是「反演回到原处」,也就是自反或对合性质。许多编码与解码、加密与解密、开关与还原
都可以用这一结构来建模。我们在此用取负作为最简示例,并用 @qc.involutory 表达「再应用一次即可回原值」。
///|
test "@qc.involutory neg" {
let prop = @qc.forall(@qc.int_range(-100, 100), @qc.involutory(Int::neg))
@qc.quick_check(prop)
}
当需求描述的是「不同实现应给出相同结果」时,@qc.ext_equal 是更直接的表达。它并不关心内部算法,
而只要求两个实现对所有输入给出相同输出,这一点非常适合重构或优化后的回归验证。
///|
fn double1(x : Int) -> Int {
x + x
}
///|
fn double2(x : Int) -> Int {
x * 2
}
///|
test "@qc.ext_equal for double" {
let prop = @qc.forall(
@qc.int_range(-100, 100),
@qc.ext_equal(double1, double2),
)
@qc.quick_check(prop)
}
有些需求体现的是「可逆」的含义,这时我们也可以用 @qc.inverse 来描述它。我们通过一个增量和减量函数
来表达可逆性,并在受控的输入范围内验证这一关系。注意这里我们仍用生成器限制输入,避免超出语义前提。
///|
fn inc(x : Int) -> Int {
x + 1
}
///|
fn dec(x : Int) -> Int {
x - 1
}
///|
test "@qc.inverse for inc/dec" {
let prop = @qc.forall(@qc.int_range(-100, 100), @qc.inverse(inc, dec))
@qc.quick_check(prop)
}
从这些例子可以看到,性质设计的关键并不在于「写更多断言」,而在于选择正确的结构来表达需求。 当我们将需求映射为代数规律或等价关系时,测试就不再是碎片化的样例,而是对整个输入空间的系统抽样。 此外,性质并非越强越好。过强的性质可能隐含不真实的假设,过弱的性质又难以约束实现。 因此我们在设计时需要让性质可解释、可证伪,同时尽量与需求文本保持可追溯的对应关系。
易错点
代 数性质很常见,但它并非银弹,在设计时更需注意以下易错点:
- 部分函数:当性质涉及除零、下标越界等部分函数时,需确保生成器避免这些输入,或在性质中处理异常情况。
- 浮点数:浮点数的精度与特殊值(NaN、Infinity)可能导致性质失效,需谨慎设计生成器与性质逻辑,并且不应该 直接用等式比较浮点数,考虑误差界 。
- 分布问题:性质设计时需考虑生成器的分布是否合理,过于均匀或偏态的分布可能导致测试覆盖不足, 在后面的生成器章节我们将详细讨论这一点。