rapid:Go语言属性测试库,自动生成测试用例并最小化失败案例

Rapid is a modern Go property-based testing library

分支5Tags41
当前项目代码仓暂无内容

rapid PkgGoDev CI

Rapid 是一个用于基于属性测试的 Go 库。

Rapid 会检查您定义的属性在大量自动生成的测试用例中是否都成立。如果发现失败情况,Rapid 会在呈现之前自动将失败的测试用例最小化。

特性

  • 命令式 Go API,使用泛型实现类型安全的数据生成
  • 数据生成偏向于更深入地探索“小”值和边缘情况
  • 失败测试用例的全自动最小化
  • 最小化失败测试用例的持久化和自动重新运行
  • 支持状态机(“有状态”或“基于模型”)测试
  • 除 Go 标准库外无其他依赖

如果您喜欢 rapid,但正在寻找 Rust 替代方案,请查看 chaos_theory

示例

以下是一个使用 rapid 的简单测试示例(playground):

package rapid_test

import (
	"slices"
	"testing"

	"pgregory.net/rapid"
)

func TestSortStrings(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		s := rapid.SliceOf(rapid.String()).Draw(t, "s")
		slices.Sort(s)
		if !slices.IsSorted(s) {
			t.Fatalf("unsorted after sort: %v", s)
		}
	})
}

更完整的示例:

对比

Rapid 旨在为 Go 带来 Hypothesis 为 Python 带来的强大功能与便捷性。

testing.F.Fuzz 相比,rapid 在生成复杂结构化数据(包括状态机测试)方面表现出色,但缺乏覆盖率引导的反馈和突变功能。请注意,通过 MakeFuzz,任何 rapid 测试都可以用作标准模糊测试器的模糊目标。

gopter 相比,rapid 提供了更为简洁的 API(rapid 中的队列测试gopter 中的队列测试),在数据生成方面更为智能,并且能够完全自动地最小化失败的测试用例,无需任何用户代码。

至于 testing/quick,它既缺乏便捷的数据生成工具,也没有任何形式的测试用例最小化功能,而这两点正是属性测试库的主要价值所在。

常见问题

什么是属性测试?

假设我们编写了算术函数 addsubtractmultiply 并想要测试它们。传统的测试方法是基于示例的——我们想出示例输入和输出,并验证系统行为是否与这些示例相符:

func TestArithmetic_Example(t *testing.T) {
	t.Run("add", func(t *testing.T) {
		examples := [][3]int{
			{0, 0, 0},
			{0, 1, 1},
			{2, 2, 4},
			// ...
		}
		for _, e := range examples {
			if add(e[0], e[1]) != e[2] {
				t.Fatalf("add(%v, %v) != %v", e[0], e[1], e[2])
			}
		}
	})
	t.Run("subtract", func(t *testing.T) { /* ... */ })
	t.Run("multiply", func(t *testing.T) { /* ... */ })
}

相比之下,在基于属性的测试中,我们定义了应适用于任意输入的更高级别的属性。每次运行基于属性的测试时,都会使用一组新的伪随机数据来检查这些属性:

func TestArithmetic_Property(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		var (
			a = rapid.Int().Draw(t, "a")
			b = rapid.Int().Draw(t, "b")
			c = rapid.Int().Draw(t, "c")
		)
		if add(a, 0) != a {
			t.Fatalf("add() does not have 0 as identity")
		}
		if add(a, b) != add(b, a) {
			t.Fatalf("add() is not commutative")
		}
		if add(a, add(b, c)) != add(add(a, b), c) {
			t.Fatalf("add() is not associative")
		}
		if multiply(a, add(b, c)) != add(multiply(a, b), multiply(a, c)) {
			t.Fatalf("multiply() is not distributive over add()")
		}
		// ...
	})
}

属性测试比基于示例的测试更强大、更简洁,而且编写起来也更有趣。此外,提炼系统的通用属性往往能改进系统本身的设计。

我应该测试哪些属性?

从上面的示例中可以看出,这取决于你要测试的系统。通常,一个好的起点是站在用户的角度,思考用户在使用你的系统时会依赖哪些属性(通常是无意识或隐含的)。不过,以下是一些广泛适用且常见的属性,供你参考:

  • 函数在输入有效数据时不会 panic
  • 两种算法或数据结构的行为完全一致
  • 所有 decode(encode(x)) == x 形式的往返操作都成立

rapid 的工作原理是什么?

rapid 的核心功能相当简单:根据你提供的规范生成伪随机数据,然后检查你针对生成数据定义的属性。

检查过程很简单:你只需编写 if 语句,并在发现问题时调用 t.Fatalf 之类的方法。

生成过程则稍微复杂一些。当你构建一个 Generator 时,并不会立即生成数据:Generator 只是一个关于如何 Draw 你所需数据的规范。当你调用 Draw 时,rapid 会从其内部的随机位流中获取一些字节,根据 Generator 规范使用这些字节来构造值,并跟踪所用的随机字节如何对应到该值(及其子部分)。这种关于生成值结构的知识,以及它们与位流部分的关系,使 rapid 能够智能且自动地简化发现的任何故障。

模糊测试(fuzzing)呢?

属性测试注重快速反馈循环:在几分之一秒内,针对少量但多样化的伪随机输入集检查属性。

相比之下,模糊测试则侧重于缓慢的、通常持续数天的暴力输入生成,以最大化覆盖率。

这两种方法都很有用。在开发过程中,属性测试与常规的基于示例的测试一起使用,而模糊测试则用于搜索边缘情况和安全漏洞。通过 MakeFuzz,任何 rapid 测试都可以用作模糊测试目标。

用法

像往常一样运行 go test 即可,它也会自动识别所有 rapid 测试。

有一些可选标志可以影响 rapid 的行为,运行 go test -args -h 并查看以 -rapid. 为前缀的标志。然后你可以像往常一样传递这些标志。例如:

go test -rapid.checks=10_000

所有以 -rapid. 为前缀的标志都有一个对应的 RAPID_ 环境变量(将点替换为下划线并将名称大写)。例如,RAPID_CHECKS=10 go test 会将 -rapid.checks=10 设置为默认值,当同时存在显式标志和环境变量时,显式标志仍会覆盖环境变量的默认值。

状态

Rapid 是稳定的:使用 rapid 的测试应该能在所有具有相同主版本号的未来 rapid 版本中继续工作。此规则可能的例外情况是,API 更改将参数的具体类型替换为接口类型,或其他类似的大部分非破坏性更改。

许可证

Rapid 根据 Mozilla 公共许可证 2.0 版 获得许可。