各位靓仔靓女,晚上好!我是你们今晚的Property-Based Testing(简称PBT)讲座主讲人,今天咱们不讲理论,来点实在的,聊聊怎么用PBT把代码里的Bug揪出来。
先问大家一个问题,你们写单元测试的时候,是不是经常绞尽脑汁想各种边界条件? 比如,一个加法函数,你会想到测 0 + 0, 1 + 1, -1 + 1, Integer.MAX_VALUE + 1… 是不是感觉永无止境? 而且,总有你没想到的情况,然后线上就boom了…
PBT 就是来拯救你们的! 它能自动生成各种各样的测试用例,帮你覆盖你可能忽略的边界情况,甚至发现你代码里隐藏的逻辑错误。 听起来是不是很酷? 别急,咱们一步一步来。
什么是 Property-Based Testing?
想象一下,你写了一个排序函数, 你要怎么测试它? 你可能会写几个固定的测试用例:
// 传统的单元测试
describe('sort function', () => {
it('should sort an empty array', () => {
expect(sort([])).toEqual([]);
});
it('should sort a simple array', () => {
expect(sort([3, 1, 4, 1, 5, 9, 2, 6])).toEqual([1, 1, 2, 3, 4, 5, 6, 9]);
});
});
这些测试用例很好,但是它们只能覆盖非常有限的情况。 你怎么知道你的排序函数对包含重复元素的数组,对已经排序的数组,对负数数组,对超大数组,等等情况都有效呢?
PBT 的思路是这样的: 你不是要写具体的测试用例,而是要定义属性 (Property)。 比如,对于排序函数,一个重要的属性是:排序后的数组必须是有序的。 另一个属性是:排序后的数组包含的元素必须和原始数组一样(只是顺序不同)。
PBT 框架会 自动生成 很多不同的输入数据,然后 验证 你的函数是否满足这些属性。 如果你的函数在某个输入数据下违反了属性,PBT 就会报告一个 失败案例 (Failure Case)。
PBT 的核心概念
PBT 的核心概念主要有三个:
- Generator (生成器): 用于生成随机的测试数据。
- Property (属性): 用于描述被测函数的行为特征,也就是我们期望函数满足的条件。
- Shrinking (收缩): 当测试失败时,PBT 会尝试找到导致失败的 最小 的输入数据。
Generator (生成器)
Generator 是 PBT 的基石。 它负责生成各种各样的测试数据。 不同的 PBT 框架提供了不同的 Generator,你可以根据需要选择合适的 Generator。
常见的 Generator 包括:
- Integer Generator (整数生成器): 生成随机整数。
- String Generator (字符串生成器): 生成随机字符串。
- Boolean Generator (布尔值生成器): 生成随机布尔值。
- Array Generator (数组生成器): 生成随机数组。
- Object Generator (对象生成器): 生成随机对象。
大多数 PBT 框架允许你自定义 Generator,以生成更符合你需求的测试数据。
Property (属性)
Property 是 PBT 的灵魂。 它描述了你期望被测函数满足的条件。 Property 通常是一个函数,它接受生成的测试数据作为输入,然后返回一个布尔值,表示该数据是否满足属性。
例如,对于排序函数,我们可以定义以下属性:
isSorted(arr)
: 检查数组arr
是否是有序的。sameElements(arr1, arr2)
: 检查数组arr1
和arr2
是否包含相同的元素(只是顺序可能不同)。
Shrinking (收缩)
Shrinking 是 PBT 的一个非常强大的特性。 当测试失败时,PBT 会尝试找到导致失败的 最小 的输入数据。 这可以帮助你更容易地理解 Bug 的原因。
例如,假设你的排序函数在输入 [3, 1, 4, 1, 5, 9, 2, 6]
时失败了。 PBT 可能会尝试以下输入数据:
[3, 1, 4, 1, 5, 9, 2]
[3, 1, 4, 1, 5, 9]
[3, 1, 4, 1, 5]
[3, 1, 4, 1]
[3, 1, 4]
[3, 1]
[1]
最终,PBT 可能会找到一个更小的输入数据 [3, 1]
,它也能导致排序函数失败。 这样,你就可以更容易地定位 Bug 的原因了。
一个简单的例子: 加法函数
让我们从一个简单的例子开始:一个加法函数。
function add(a, b) {
return a + b;
}
我们想要测试这个函数。 使用 PBT,我们可以定义一个属性:对于任何两个数字 a
和 b
,add(a, b)
的结果应该等于 b + a
。
假设我们使用一个名为 jsverify
的 PBT 框架(当然,还有其他的框架,比如fast-check,我们稍后会提到)。 首先,你需要安装 jsverify
:
npm install jsverify
然后,你可以编写以下测试代码:
const jsc = require('jsverify');
describe('add function', () => {
it('should be commutative', () => {
jsc.assert(jsc.forall(jsc.integer, jsc.integer, (a, b) => {
return add(a, b) === add(b, a);
}));
});
});
让我们分解一下这段代码:
jsc.assert
: 这是jsverify
提供的断言函数。 它接受一个 Property 作为参数。jsc.forall
: 这是一个 Property Combinator。 它接受两个 Generator (这里是jsc.integer
和jsc.integer
) 和一个函数作为参数。 这个函数描述了我们想要验证的属性。jsc.integer
: 这是一个 Integer Generator。 它生成随机整数。(a, b) => { return add(a, b) === add(b, a); }
: 这是一个匿名函数,它接受两个整数a
和b
作为参数,然后返回一个布尔值,表示add(a, b)
是否等于add(b, a)
。
这段代码的意思是:对于任何两个整数 a
和 b
,我们断言 add(a, b)
应该等于 add(b, a)
。 jsverify
会自动生成很多不同的整数对,然后验证这个属性是否成立。
一个更复杂的例子: 排序函数
现在,让我们来看一个更复杂的例子:排序函数。 假设我们有以下排序函数:
function sort(arr) {
return arr.slice().sort((a, b) => a - b); //为了不修改原数组,做一次浅拷贝
}
我们想要测试这个函数。 使用 PBT,我们可以定义以下属性:
- 排序后的数组必须是有序的。
- 排序后的数组包含的元素必须和原始数组一样(只是顺序不同)。
我们可以使用以下代码来测试这个函数:
const jsc = require('jsverify');
// 检查数组是否是有序的
function isSorted(arr) {
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
return false;
}
}
return true;
}
// 检查两个数组是否包含相同的元素(忽略顺序)
function sameElements(arr1, arr2) {
if (arr1.length !== arr2.length) {
return false;
}
const counts1 = {};
const counts2 = {};
for (const element of arr1) {
counts1[element] = (counts1[element] || 0) + 1;
}
for (const element of arr2) {
counts2[element] = (counts2[element] || 0) + 1;
}
for (const key in counts1) {
if (counts1[key] !== counts2[key]) {
return false;
}
}
return true;
}
describe('sort function', () => {
it('should sort an array', () => {
jsc.assert(jsc.forall(jsc.array(jsc.integer), (arr) => {
const sortedArr = sort(arr);
return isSorted(sortedArr) && sameElements(arr, sortedArr);
}));
});
});
让我们分解一下这段代码:
jsc.array(jsc.integer)
: 这是一个 Array Generator。 它生成一个包含随机整数的数组。(arr) => { ... }
: 这是一个匿名函数,它接受一个数组arr
作为参数,然后返回一个布尔值,表示排序后的数组是否满足我们的属性。isSorted(sortedArr) && sameElements(arr, sortedArr)
: 这表示排序后的数组必须是有序的,并且包含的元素必须和原始数组一样。
发现 Bug!
现在,假设我们的排序函数有一个 Bug:它没有正确处理重复的元素。 比如,对于输入 [3, 1, 4, 1, 5, 9, 2, 6]
,它可能会返回 [1, 2, 3, 4, 5, 6, 9]
(缺少一个 1)。
如果我们运行上面的 PBT 测试,它会很快发现这个 Bug。 jsverify
会报告一个失败案例,并尝试找到导致失败的最小的输入数据。 你可能会看到类似这样的输出:
Failed after 1 tests and 1 shrinks. rngState: 00000000000000000000
Counterexample: [ 1, 1 ]
这个输出告诉我们,当输入为 [1, 1]
时,我们的排序函数失败了。 这可以帮助我们更容易地定位 Bug 的原因。
使用 Fast-Check
除了 jsverify
,还有其他的 PBT 框架可以使用。 其中一个流行的框架是 fast-check
。 fast-check
比 jsverify
更快,更灵活。
要使用 fast-check
,首先你需要安装它:
npm install fast-check
然后,你可以编写以下测试代码:
const fc = require('fast-check');
// 检查数组是否是有序的
function isSorted(arr) {
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
return false;
}
}
return true;
}
// 检查两个数组是否包含相同的元素(忽略顺序)
function sameElements(arr1, arr2) {
if (arr1.length !== arr2.length) {
return false;
}
const counts1 = {};
const counts2 = {};
for (const element of arr1) {
counts1[element] = (counts1[element] || 0) + 1;
}
for (const element of arr2) {
counts2[element] = (counts2[element] || 0) + 1;
}
for (const key in counts1) {
if (counts1[key] !== counts2[key]) {
return false;
}
}
return true;
}
describe('sort function', () => {
it('should sort an array', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sortedArr = sort(arr);
return isSorted(sortedArr) && sameElements(arr, sortedArr);
})
);
});
});
这段代码和使用 jsverify
的代码非常相似。 主要的区别是,我们使用 fc.property
来定义 Property,而不是 jsc.forall
。
自定义 Generator
有时候,你需要生成更符合你需求的测试数据。 这时,你可以自定义 Generator。
例如,假设你有一个函数,它只接受正整数作为输入。 你可以自定义一个 Generator,只生成正整数:
const jsc = require('jsverify');
const positiveInteger = jsc.integer.smap(
(n) => Math.abs(n) + 1, // 将负数和零转换为正数
(n) => n - 1, // 反向转换
'positiveInteger'
);
describe('positive integer function', () => {
it('should accept only positive integers', () => {
jsc.assert(jsc.forall(positiveInteger, (n) => {
return n > 0;
}));
});
});
在这个例子中,我们使用了 jsc.integer.smap
来创建一个新的 Generator。 smap
接受三个参数:
- 一个函数,用于将原始 Generator 生成的值转换为新的值。
- 一个函数,用于将新的值转换回原始值(用于 Shrinking)。
- 一个字符串,用于描述新的 Generator。
总结
PBT 是一种强大的测试技术,可以帮助你发现代码里隐藏的 Bug。 它的核心概念包括:
- Generator (生成器): 用于生成随机的测试数据。
- Property (属性): 用于描述被测函数的行为特征。
- Shrinking (收缩): 当测试失败时,PBT 会尝试找到导致失败的 最小 的输入数据。
以下表格总结了 PBT 的优点和缺点:
优点 | 缺点 |
---|---|
自动生成测试用例,覆盖更多的情况 | 需要定义 Property,这可能比编写具体的测试用例更难 |
可以发现传统单元测试难以发现的 Bug | 可能会生成大量的测试数据,导致测试时间变长 |
Shrinking 可以帮助你更容易地理解 Bug 的原因 | 需要选择合适的 Generator,否则可能会生成无意义的测试数据 |
可以与其他测试技术结合使用,例如单元测试和集成测试 | 如果 Property 定义不正确,可能会导致测试结果不准确 |
总而言之,PBT 是一种非常有价值的测试技术,值得你学习和使用。 它可以帮助你提高代码质量,减少 Bug 的数量,让你更有信心地上线你的代码。
一些建议
- 从简单的函数开始,逐步尝试 PBT。
- 花时间定义正确的 Property。
- 选择合适的 Generator。
- 不要害怕自定义 Generator。
- 与其他测试技术结合使用。
- 持续学习和实践。
好了,今天的讲座就到这里。希望大家能从今天的讲座中有所收获,并在自己的项目中尝试使用 PBT。 如果有什么问题,欢迎随时提问。 祝大家编码愉快!