01/什么是 Property-Based Testing?
Property-Based Testing 起源于 2000 年由 Koen Claessen 及 John Hughes 两位教授所写的 QuickCheck: a lightweight tool for random testing of Haskell programs 论文中所介绍的 Quick Check 此 Haskell 的 testing framework。此后,也启发各个语言的 Property-Based Testing framework,例如:Python 的 Hypothesis、Erlang 的 PropEr、Rust 的 quickcheck、JS/TS 的 fast-check 等等。
而 Property-Based Testing 有什么优缺点呢?为什么各语言都争相做出该语言的 Property-Based Testing 框架呢?本文将简介 Property-Based Testing 有哪些特性,并通过范例来解释 Property-Based Testing 与传统的 Example-Based Testing 有什么不同。
※ 本文将使用 JS/TS 的 Property-Based Testing 框架 fast-check 范例来介绍如何通过 Property-Based Testing 做测试。在开始介绍前,我们先讨论一下「传统的 Example-Based Testing」指的是什么以及它存在什么问题。
02/Don’t write tests!
John Hughes 在 Curry On! 2017 做了一场演讲名为 Don't Write Tests!。为什么它会下这种标题呢?让我们来举例并逐步了解。
假设今天有两个function reverse 以及 sort,我们必须针对它们写测试用例,在 Example-Based Testing 中,你的测试可能会这样写
describe(`Test ${reverse.name}`, () => {
it('should reverse the array', () => {
expect(reverse([1, 2, 3])).toEqual([3, 2, 1]);
expect(reverse([1, 2, 3, 4, 5])).toEqual([5, 4, 3, 2, 1]);
});
});
describe(`Test ${sort.name}`, () => {
it('should sort the array in ascending order', () => {
expect(sort([1, 3, 5, 4, 2])).toEqual([1, 2, 3, 4, 5]);
expect(sort([2, 1, 3, 4])).toEqual([1, 2, 3, 4]);
expect(sort([5, 4, 3, 2, 1])).toEqual([1, 2, 3, 4, 5]);
});
});
这样可以简单的测试 reverse 是否正确地反转给定的 array 或是 sort 是否正确地排序给定的 array,但这样有什么问题或是缺点呢?
03/Example-Based Testing 的问题是什么?
在以上测试中,可以看到我们必须手刻 input array 并写好它的 expected output array。也就是说,我们只会测到既定的 input 以及 output,这样导致我们可能会错过不少案例。为了不要手刻 input array,我们可以将测试(以下只举 sort 为例)改为
describe(`Test ${reverse.name}`, () => {
it('should reverse the array', () => {
expect(reverse([1, 2, 3])).toEqual([3, 2, 1]);
expect(reverse([1, 2, 3, 4, 5])).toEqual([5, 4, 3, 2, 1]);
});
});
describe(`Test ${sort.name}`, () => {
it('should sort the array in ascending order', () => {
expect(sort([1, 3, 5, 4, 2])).toEqual([1, 2, 3, 4, 5]);
expect(sort([2, 1, 3, 4])).toEqual([1, 2, 3, 4]);
expect(sort([5, 4, 3, 2, 1])).toEqual([1, 2, 3, 4, 5]);
});
});
这样可以简单的测试 reverse 是否正确地反转给定的 array 或是 sort 是否正确地排序给定的 array,但这样有什么问题或是缺点呢?
Example-Based Testing 的问题是什么?
在以上测试中,可以看到我们必须手输 input array 并写好它的 expected output array。也就是说,我们只会测到既定的 input 以及 output,这样导致我们可能会错过不少案例。
为了不要手输 input array,我们可以将测试(以下只举 sort 为例)改为
function range(n: number): Array<number> {
return Array.from({ length: n }).map((_, i) => i + 1);
}
describe(`Test ${sort.name}`, () => {
it('should sort the array in ascending order', () => {
expect(sort(range(3).reverse())).toEqual(range(3));
expect(sort(range(5).reverse())).toEqual(range(5));
expect(sort(range(10).reverse())).toEqual(range(10));
});
});
range function 可以产生一个由 1 到 N 的阵列,但由于它已经是一个排序好的阵列,我们必须先通过一些方法(此范例使用 .reverse)来打乱阵列顺序后,再通过执行 sort function 来确认是否已经排序好了。这样的优点是,我们已经可以通过产生出的阵列来测试,并打乱它后再确认是否已经排序,但是,这样我们还是只有测了「长度为 3 且内容物为 1 到 3 的阵列」、「长度为 5 且内容物为 1 到 5 的阵列」以及「长度为 10 且内容物为 1 到 10 的阵列」。
因此,为了让测试涵盖更多情况,我们可以再把测试改为
function range(n: number): Array<number> {
return Array.from({ length: n }).map((_, i) => i + 1);
}
function genReversedArr(n: number): Array<number> {
return range(n).reverse();
}
describe(`Test ${sort.name}`, () => {
it('should sort the array in ascending order', () => {
for (let n = 1; n < 100; n++) {
expect(sort(genReversedArr(n))).toEqual(range(n));
}
});
});
这样我们已经能测到从长度为 1 至长度为 100 的阵列,但通过 range 产生之阵列的数字间隔都只为 1(ex. [1,2,3] 或是 [1,2,3,4,5]),如果在 sort 的实作中只有处理到间隔为 1 的情况,那有可能在 input array 为 [3,5,7,9,0] 时就发生错误了。
但由上列范例可以看到,通过自动产生的方式,可以减少我们手刻 input 及 output 的情形,但产生之 input 的涵盖范围可能还是不够广泛,那到底该怎么办呢?
接下来,让我们看看在 Property-Based Testing 中又会如何测试 reverse 及 sort。
04/Don’t write tests! Generate them!

在上一段落可以看到,通过自动产生之方式,可以避免我们在手刻 input 及 output 时写错且可以让我们更轻鬆的测试更多 test cases,因此 John Hughes 在 Curry On! 2017 的 Don't Write Tests! 一演讲中提到的是,我们不应该「写」测试,而是要「自动产生」它们。
Property-Based Testing 会针对定义好的每个 property 通过 property-based testing 框架提供的 generator 任意产生出 100 个 test cases(通常预设为 100 个 test cases),这也是 Property-Based Testing 与 Example-Based Testing 最大的不同。
因为我们不再是只有 reverse([1,2,3]) == [3,2,1],所以我们必须去思考,到底什么是 property 呢?
05/何谓 Property?
一个测试代表著对该测试对象的证明。因此,一个 property 可视为该测试对象的「标准」(invariants)或是「规格」(specification)。
Property-Based Testing 的核心概念在 Property-Based Testing 中,最为重要的就是它有以下三样东西:
-
Arbitrary(乱数生成器)
-
Generator(测试生成器)
-
Shrinker(误区识别器)
Arbitrary(乱数生成器)
arbitrary 是用来告诉 pbt framework 要如何针对某 type 产生它的值,例如 fast-check 提供了 fc.boolean(),由 boolean type 可知,它可能会是 true 或是 false,通常 framework 会提供 primitive types 的 arbitrary(例如:fast-check 提供了 fc.string()、fc.integer() 等等),当然根据自定义的 type 去定义它的 arbitrary 也是可以的。
例如:
// in types.ts
export type User = Readonly<{
name: string;
address: string;
age: number;
}>;
export const Color = {
Red: null,
Blue: null,
Green: null,
};
export type Color = keyof typeof Color;
// in arbitraries.ts
export const user = fc.record<User>({
name: fc.string(),
address: fc.string(),
age: fc.nat(), // nat 代表 natural number(自然數),畢竟人的歲數不會是負的 ^__^
});
export const color = fc.oneof(Object.keys(Color));
Generator(测试生成器)
Generator 则是 pbt framework 会通过 random 的方式从该 property 给的 arbitrary 中去随机产生出 N 个 test cases(通常 N 预设为 100)。随机方式可能依据每个 framework 而不同。
Shrinker(误区识别器)
Shrinker 则是 pbt framework 在发现 failure test case 时,会通过 shrinker 找到 minimal failure test case。但不是每个 pbt framework 都支持shrinker。
所以我们该如何通过 Property-Based Testing 测试 reverse 及 sort 呢?
对 reverse function 而言,reverse 的 property 就是:一个 array 反转两次必须等同于原本的 array。反转一次的 array 的第一个 element 必须等于原 array 的最后一个 element(例如:reversed[0] === original[n - 1]、reversed[1] === original[n - 2] 等),依序递增检查。通过 fast-check 的 pbt test 如下 👇
const fc = require('fast-check');
test(`Test ${reverse.name}`, () => {
fc.assert(
// 透過 `fc.array(fc.string())` 定義 input 為 Array<string>
fc.property(fc.array(fc.string()), arr => {
// 測試 invariant 1
expect(reverse(reverse(arr))).toEqual(arr);
// 測試 invariant 2
const reversed = reverse(arr);
for (let i = 0; i < arr.length; i++) {
expect(reversed[i]).toEqual(arr[arr.length - i - 1]);
}
})
);
});
而对 sort function 而言, sort 的 property 则是:
1. 排序完的 array 必须与原 array 等长
2. 排序后的 array 的 sorted[i - 1] 都必须 <= sorted[i]
通过 fast-check 的 pbt test 如下 👇
const fc = require('fast-check');
test(`Test ${sort.name}`, () => {
fc.assert(
// 透過 `fc.array(fc.integer())` 定義 input 為 Array<number>
fc.property(fc.array(fc.integer()), arr => {
const sorted = sort(arr);
// 測試 invariant 1
expect(sorted.length).toEqual(arr.length);
// 測試 invariant 2
for (let idx = 1; idx < sorted.length; ++idx) {
expect(sorted[idx - 1]).toBeLessThanOrEqual(sorted[idx]);
}
})
);
});
测试结果如下:
但如果我们不小心记成 sorting 时是 sorted[i - 1] < sorted[i] 使用了 toBeLessThan 而非 <=,那 shrinker 则会帮助我们找出这项错误,但由于 input 每次都是乱数产生的,所以不是每次都可以抓出此错误,可是 shrinker 可以帮助我们快速理解为何有误:

通过 Property-Based Testing 的方式,可以让我们能够重新思考testing target 拥有哪些 invariant 并重新检视我们的厕所是否符合这些规则。
- END -
下方扫码关注 软件质量保障,与质量君一起学习成长、共同进步,做一个职场最贵Tester!
欢迎关注「软件质量保障」微信公众号

往期推荐