#properties #quickcheck #property-testing #fuzz #shrinking #testing

quickcheck_seedable

具有缩小的基于属性的自动测试

1 个不稳定版本

使用旧的 Rust 2015

0.3.1 2016 年 9 月 13 日

#1209算法

Unlicense/MIT

63KB
1.5K SLoC

QuickCheck 是一种使用随机生成的输入进行属性测试的方法。该软件包包含随机生成和缩小整数、浮点数、元组、布尔值、列表、字符串、选项和结果的能力。QuickCheck 所需的只是一个属性函数——然后它将随机生成该函数的输入并针对每组输入调用属性。如果属性失败(无论是由于运行时错误,如索引越界,还是不满足您的属性),则输入将被“缩小”以找到更小的反例。

列表和数字的缩小策略使用二分搜索快速覆盖输入空间。(它应该与 Koen Claessen 的 Haskell QuickCheck 中使用的相同策略。)

Build status

MIT 或 UNLICENSE 双许可。

文档

API 已完全文档化: http://burntsushi.net/rustdoc/quickcheck/.

简单示例

以下是一个测试反转向量的函数的示例

#[cfg(test)]
#[macro_use]
extern crate quickcheck;

fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = vec!();
    for x in xs.iter() {
        rev.insert(0, x.clone())
    }
    rev
}

#[cfg(test)]
mod tests {
  quickcheck! {
      fn prop(xs: Vec<u32>) -> bool {
          xs == reverse(&reverse(&xs))
      }
  }
}

此示例使用 quickcheck! 宏,该宏在稳定 Rust 中可用。

#[quickcheck] 属性(需要 Rust 夜间版本)

为了更容易编写 QuickCheck 测试,#[quickcheck] 属性将属性函数转换为 #[test] 函数。

要使用 #[quickcheck] 属性,您必须启用 plugin 功能并将 quickcheck_macros 软件包作为语法扩展导入

#![feature(plugin)]
#![plugin(quickcheck_macros)]

#[cfg(test)]
extern crate quickcheck;

#[cfg(test)]
mod tests {
    fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
        let mut rev = vec!();
        for x in xs {
            rev.insert(0, x.clone())
        }
        rev
    }

    #[quickcheck]
    fn double_reversal_is_identity(xs: Vec<isize>) -> bool {
        xs == reverse(&reverse(&xs))
    }
}

安装

quickcheckcrates.io 上,因此您可以将它包含到项目中,如下所示

[dependencies]
quickcheck = "0.3"

如果您的测试代码中只使用了 quickcheck,则可以将它添加为开发依赖项

[dev-dependencies]
quickcheck = "0.3"

如果您想使用 #[quickcheck] 属性,请添加 quickcheck_macros

[dev-dependencies]
quickcheck = "0.3"
quickcheck_macros = "0.2"

并只为测试构建启用 quickcheck_macros 插件

#![cfg_attr(test, feature(plugin))]
#![cfg_attr(test, plugin(quickcheck_macros))]

注意,当 Rust 1.0 稳定版发布时,#[quickcheck] 宏将不会工作,尽管它将在夜间构建中继续工作。

注意:当使用 quickcheck(无论是直接使用还是通过属性)时,RUST_LOG=quickcheck 启用 info! 以显示有用的输出(如通过测试的数量)。这不是显示失败证据所需的。

丢弃测试结果(或,属性是多态的!)

有时您只想测试对于可能输入的 子集 而言成立的属性,所以当您的属性被给定一个在这个子集之外的输入时,您会丢弃它。特别是,属性应该在您想要测试的子集之外的输入上既不通过也不失败。但是属性返回布尔值——这表示通过或失败。

为了解决这个问题,我们需要退后一步,看看 quickcheck 函数的类型

pub fn quickcheck<A: Testable>(f: A) {
    // elided
}

所以 quickcheck 可以测试任何满足 Testable 特质的类型。很好,那么这个 Testable 是什么意思?

pub trait Testable {
    fn result<G: Gen>(&self, &mut G) -> TestResult;
}

此特质表明,如果类型可以给定一个随机数源产生一个 TestResult,则类型是可测试的。(TestResult 存储有关测试结果的信息,例如它是否通过、失败或被丢弃。)

确实如此,bool 满足 Testable 特质

impl Testable for bool {
    fn result<G: Gen>(&self, _: &mut G) -> TestResult {
        TestResult::from_bool(*self)
    }
}

但在示例中,我们向 quickcheck 提供了一个 函数。是的,函数也可以满足 Testable

impl<A: Arbitrary + Debug, B: Testable> Testable for fn(A) -> B {
    fn result<G: Gen>(&self, g: &mut G) -> TestResult {
        // elided
    }
}

这意味着一个函数满足 Testable,当且仅当它有一个单一参数类型(其值可以随机生成和缩小)并返回任何类型(也满足 Testable)。因此,类型为 fn(usize) -> bool 的函数满足 Testable,因为 usize 满足 Arbitrarybool 满足 Testable

所以,为了丢弃一个测试,我们需要返回除了 bool 之外的东西。如果我们直接返回一个 TestResult 会怎样?那应该可以工作,但我们需要确保 TestResult 满足 Testable

impl Testable for TestResult {
    fn result<G: Gen>(&self, _: &mut G) -> TestResult { self.clone() }
}

现在我们可以直接测试返回 TestResult 的函数。

作为一个例子,让我们测试我们的 reverse 函数,以确保长度为 1 的向量的反转等于自身。

fn prop(xs: Vec<isize>) -> TestResult {
    if xs.len() != 1 {
        return TestResult::discard()
    }
    TestResult::from_bool(xs == reverse(&xs))
}
quickcheck(prop as fn(Vec<isize>) -> TestResult);

(此示例的完整工作程序在 examples/reverse_single.rs。)

现在我们的属性返回一个 TestResult,这使我们能够编码更多的信息。为 TestResult 类型定义了一些额外的便捷函数。[更多详情](http://burntsushi.net/rustdoc/quickcheck/struct.TestResult.html) 。例如,我们不能只是返回一个 bool,所以我们将一个 bool 值转换为 TestResult

(忽略测试的能力可以让你获得类似 Haskell 的 ==> 组合子。)

注意:由于忽略测试意味着它既不通过也不失败,因此 quickcheck 将尝试用一个全新的测试来替换被忽略的测试。然而,如果你的条件很少满足,则 quickcheck 可能不得不运行比平常更少的测试。默认情况下,如果 quickcheck 在尝试了 10,000 次后,仍然找不到 100 个有效的测试,那么它将放弃。此参数可以使用 quickcheck_config 进行更改。

缩小

缩小是 QuickCheck 的关键部分,它自动简化了属性的逆例。例如,如果你错误地将一个用于反转向量的函数定义为:(对于这个精心制作的例子,我表示歉意)

fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = vec![];
    for i in 1..xs.len() {
        rev.insert(0, xs[i].clone())
    }
    rev
}

然后有一个用于测试的属性,检查 xs == reverse(reverse(xs))

fn prop(xs: Vec<isize>) -> bool {
    xs == reverse(&reverse(&xs))
}
quickcheck(prop as fn(Vec<isize>) -> bool);

那么如果没有缩小,你可能会得到这样的逆例

[quickcheck] TEST FAILED. Arguments: ([-17, 13, -12, 17, -8, -10, 15, -19,
-19, -9, 11, -5, 1, 19, -16, 6])

这相当神秘。但启用缩小后,你几乎可以保证每次都得到这个逆例

[quickcheck] TEST FAILED. Arguments: ([0])

这将更容易调试。

案例分析:埃拉托斯特尼筛法

埃拉托斯特尼筛法 是一种简单而优雅的方法,可以找到所有小于或等于 N 的素数。简要地说,算法通过分配一个包含 N 个槽的数组,其中包含布尔值来工作。标记为 false 的槽对应于素数(或构建筛子时不知道是否为素数的数字),而标记为 true 的槽则已知不是素数。对于每个 n,此数组中所有 n 的倍数都被标记为 true。当所有 n 都已检查后,标记为 false 的数字被返回作为素数。

正如你所想象的那样,这里有很多潜在的越界错误,这使得它非常适合随机测试。所以让我们来看看我的实现,看看我们是否能找到错误。

fn sieve(n: usize) -> Vec<usize> {
    if n <= 1 {
        return vec![];
    }

    let mut marked = vec![false; n+1];
    marked[0] = true;
    marked[1] = true;
    marked[2] = true;
    for p in 2..n {
        for i in (2*p..n).filter(|&n| n % p == 0) {
            marked[i] = true;
        }
    }
    marked.iter()
          .enumerate()
          .filter_map(|(i, &m)| if m { None } else { Some(i) })
          .collect()
}

让我们手动尝试几个输入

sieve(3) => [2, 3]
sieve(5) => [2, 3, 5]
sieve(8) => [2, 3, 5, 7, 8] # !!!

出了点问题!但问题出在哪里?这个错误相当微妙,但很容易犯。如果你找不到它,那没关系,因为我们将使用 QuickCheck 来帮助我们追踪它。

在查看一些示例输出之前,最好尝试想出一些总是可以通过函数输出满足的 属性。对于素数筛子来说,一个明显的属性是检查返回的所有数字是否都是素数。为此,我们需要一个 is_prime 函数

fn is_prime(n: usize) -> bool {
    n != 0 && n != 1 && (2..).take_while(|i| i*i <= n).all(|i| n % i != 0)
}

这段代码所做的事情是检查 [2, sqrt(n)] 区间内的任何数字是否能整除 n,并且对于 01 这两个基本情况也进行了处理。

现在我们可以编写我们的 QuickCheck 属性了。

fn prop_all_prime(n: usize) -> bool {
    sieve(n).into_iter().all(is_prime)
}

最后,我们需要使用我们的属性来调用 quickcheck

fn main() {
    quickcheck(prop_all_prime as fn(usize) -> bool);
}

包含这段代码的完整源文件可以在 examples/sieve.rs 中找到。

运行此程序后的输出有这条消息:

[quickcheck] TEST FAILED. Arguments: (4)

它说明当给定 n = 4 时,sieve 失败了 prop_all_prime 测试。由于缩小(shrinking),它能够找到一个(希望是)最小的反例。

对于如此简短的反例,找到错误所在可能更容易一些。由于返回 4,它可能从未被标记为非质数。由于 42 的倍数,所以当 p = 2 时,这些行上它的槽应该被标记为 true

for i in (2*p..n).filter(|&n| n % p == 0) {
    marked[i] = true;
}

啊!但 ..(范围)操作符是否包含 n?不!这个特定的操作符是一个半开区间。

2*p..n 范围在 n = 4 时永远不会产生 4。当我们将其更改为 2*p..n+1 时,所有测试都通过。

此外,如果我们的错误导致索引越界错误,那么 quickcheck 可以像处理任何其他失败一样处理它——包括处理由运行时错误引起的失败。

但是等等……我们还没有完成。现在,我们的属性测试了由 sieve 返回的所有数字都是质数,但它没有测试列表是否完整。它不能确保找到 0n 之间的所有质数。

这是一个更全面的属性。

fn prop_prime_iff_in_the_sieve(n: usize) -> bool {
    sieve(n) == (0..(n + 1)).filter(|&i| is_prime(i)).collect::<Vec<_>>()
}

它测试对于 0 和 n 之间的每个数字(包括 n),朴素质数测试的结果与筛选器相同。

现在,如果我们运行它

fn main() {
    quickcheck(prop_all_prime as fn(usize) -> bool);
    quickcheck(prop_prime_iff_in_the_sieve as fn(usize) -> bool);
}

我们看到它立即因为 n = 2 的值而失败。

[quickcheck] TEST FAILED. Arguments: (2)

如果我们再次检查 sieve(),我们会看到我们错误地将 2 标记为非质数。删除行 marked[2] = true; 使得两个属性都通过。

QuickCheck 的移植中缺少了什么?

我认为我已经捕捉到了关键特性,但仍然有一些东西缺失。

  • 到目前为止,只有具有 4 个或更少参数的函数才能进行快速检查。这个限制可以提高到 N,但这需要对每个 n 实现一个 Testable 特性的实现。
  • 由于栈溢出而失败的函数不会被QuickCheck捕获。因此,此类失败将不会有证据附件。(我想解决这个问题,但不知道如何。)
  • Coarbitrary在这个包的任何形式中都不存在。我认为这是可能的;我只是还没时间去做。

依赖关系

~4.5MB
~85K SLoC