JS `Property-Based Testing` `Generators` `Shrinking` `Failure Cases`

各位靓仔靓女,晚上好!我是你们今晚的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 的核心概念主要有三个:

  1. Generator (生成器): 用于生成随机的测试数据。
  2. Property (属性): 用于描述被测函数的行为特征,也就是我们期望函数满足的条件。
  3. 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): 检查数组 arr1arr2 是否包含相同的元素(只是顺序可能不同)。

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,我们可以定义一个属性:对于任何两个数字 abadd(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.integerjsc.integer) 和一个函数作为参数。 这个函数描述了我们想要验证的属性。
  • jsc.integer: 这是一个 Integer Generator。 它生成随机整数。
  • (a, b) => { return add(a, b) === add(b, a); }: 这是一个匿名函数,它接受两个整数 ab 作为参数,然后返回一个布尔值,表示 add(a, b) 是否等于 add(b, a)

这段代码的意思是:对于任何两个整数 ab,我们断言 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-checkfast-checkjsverify 更快,更灵活。

要使用 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。 如果有什么问题,欢迎随时提问。 祝大家编码愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注