别再傻傻地写测试了,先了解下Property-Based Testing

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.tsexport 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.tsexport 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!

欢迎关注「软件质量保障」微信公众号

往期推荐

聊聊工作中的自我管理和向上管理

经验分享|测试工程师转型测试开发历程

聊聊UI自动化的PageObject设计模式

细读《阿里测试之道》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

软件质量保障

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值