JS `Property-Based Testing` (`fast-check`):随机生成测试用例以发现边缘情况

各位观众老爷,大家好!今天咱们来聊聊一个听起来高大上,用起来贼好玩的测试方法:Property-Based Testing,也就是基于属性的测试。这玩意儿能帮你自动生成各种奇葩的测试用例,让你的代码在你想不到的角落里翻船,从而提前发现那些隐藏的 bug。准备好了吗?咱们开车了!

什么是 Property-Based Testing?

传统的单元测试,你需要自己绞尽脑汁去想各种测试用例,然后手动输入数据,再验证结果是否符合预期。这活儿干多了,你会发现自己陷入了一个怪圈:你只能想到你已经知道的 bug,而那些你不知道的 bug,永远藏在暗处。

Property-Based Testing 就不一样了。它不需要你具体指定测试用例,而是让你定义代码应该满足的属性(Property)。然后,它会帮你自动生成大量的随机测试用例,并验证这些用例是否满足你定义的属性。如果发现有不满足属性的用例,就说明你的代码有问题。

举个例子,假设你要测试一个排序函数。传统的单元测试可能需要你手动输入几个数组,然后验证排序结果是否正确。而 Property-Based Testing 可以让你定义一个属性:排序后的数组的元素个数和原数组应该一样排序后的数组应该是升序排列。然后,它会自动生成各种各样的数组,并验证这些数组是否满足这两个属性。

为什么要用 Property-Based Testing?

  • 发现边缘情况: 随机生成测试用例,能覆盖你可能忽略的边界情况和极端情况。
  • 减少测试用例编写工作量: 只需要定义属性,不需要手动编写大量的测试用例。
  • 提高代码的健壮性: 能够发现代码中潜在的 bug,提高代码的质量。
  • 更好地理解代码行为: 通过定义属性,可以更深入地理解代码的行为和约束。

Fast-Check:JS 世界的 Property-Based Testing 利器

在 JavaScript 的世界里,fast-check 是一个非常流行的 Property-Based Testing 库。它提供了强大的随机数据生成能力和灵活的属性定义方式,让你轻松上手 Property-Based Testing。

安装 Fast-Check

首先,你需要安装 fast-check

npm install fast-check --save-dev

或者使用 yarn:

yarn add fast-check --dev

一个简单的例子:字符串反转

咱们先来一个简单的例子:测试一个字符串反转函数。

// 待测试的函数
function reverseString(str) {
  return str.split('').reverse().join('');
}

现在,我们要用 fast-check 来测试这个函数。

import * as fc from 'fast-check';
import { reverseString } from './your-module'; // 假设你的函数在 your-module.js 中

describe('reverseString', () => {
  it('反转两次应该回到原字符串', () => {
    fc.assert(
      fc.property(fc.string(), (str) => {
        const reversed = reverseString(str);
        const reversedTwice = reverseString(reversed);
        return reversedTwice === str;
      })
    );
  });

  it('空字符串反转后还是空字符串', () => {
    fc.assert(
      fc.property(fc.constant(''), (str) => {
        return reverseString(str) === '';
      })
    );
  });

  it('反转后的字符串长度不变', () => {
    fc.assert(
      fc.property(fc.string(), (str) => {
        return reverseString(str).length === str.length;
      })
    );
  });
});

代码解释:

  • import * as fc from 'fast-check';: 导入 fast-check 库。
  • fc.property(fc.string(), (str) => { ... });: 定义一个属性,fc.string() 表示随机生成字符串,(str) => { ... } 是一个函数,用于验证这个属性是否成立。
  • fc.assert(...): 运行测试,fast-check 会自动生成大量的随机字符串,并验证每个字符串是否满足你定义的属性。
  • fc.constant(''): 定义一个总是生成空字符串的arbitrary.

这个例子定义了三个属性:

  1. 反转两次应该回到原字符串。
  2. 空字符串反转后还是空字符串。
  3. 反转后的字符串长度不变。

fast-check 会自动生成大量的随机字符串,并验证这些字符串是否满足这三个属性。如果发现有不满足属性的字符串,就会报错,并告诉你哪个字符串导致了错误。

更高级的用法:自定义 Arbitrary

fast-check 提供了很多内置的 Arbitrary(随机数据生成器),比如 fc.string()fc.integer()fc.boolean() 等等。但是,有时候你需要生成更复杂的数据结构,或者需要对生成的数据进行一些限制。这时候,你可以自定义 Arbitrary。

import * as fc from 'fast-check';

// 定义一个 Arbitrary,用于生成指定范围内的正整数
const positiveIntegerArbitrary = fc.integer({ min: 1, max: 100 });

describe('positiveIntegerArbitrary', () => {
  it('生成的值应该在指定范围内', () => {
    fc.assert(
      fc.property(positiveIntegerArbitrary, (num) => {
        return num >= 1 && num <= 100;
      })
    );
  });
});

代码解释:

  • fc.integer({ min: 1, max: 100 }): 创建一个 Arbitrary,用于生成 1 到 100 之间的整数。

更复杂的例子:购物车

咱们再来一个更复杂的例子:测试一个购物车的功能。

// 购物车类
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  addItem(item, quantity) {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    const existingItemIndex = this.items.findIndex(i => i.item === item);
    if (existingItemIndex > -1) {
      this.items[existingItemIndex].quantity += quantity;
    } else {
      this.items.push({ item, quantity });
    }
  }

  removeItem(item, quantity) {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    const existingItemIndex = this.items.findIndex(i => i.item === item);
    if (existingItemIndex === -1) {
      return; // Item not in cart, nothing to remove
    }
    const existingItem = this.items[existingItemIndex];
    existingItem.quantity -= quantity;
    if (existingItem.quantity <= 0) {
      this.items.splice(existingItemIndex, 1); // Remove item if quantity reaches 0
    }
  }

  getTotalItems() {
    return this.items.reduce((total, item) => total + item.quantity, 0);
  }
}

现在,我们要用 fast-check 来测试这个购物车的功能。

import * as fc from 'fast-check';
import { ShoppingCart } from './your-module'; // 假设你的购物车类在 your-module.js 中

describe('ShoppingCart', () => {
  // 定义 Arbitrary
  const itemArbitrary = fc.string({ minLength: 1, maxLength: 10 });
  const quantityArbitrary = fc.integer({ min: 1, max: 10 });

  // 定义一个 Arbitrary,用于生成购物车操作序列
  const cartActionArbitrary = fc.oneof(
    fc.record({ type: fc.constant('add'), item: itemArbitrary, quantity: quantityArbitrary }),
    fc.record({ type: fc.constant('remove'), item: itemArbitrary, quantity: quantityArbitrary })
  );

  it('添加和删除商品后,商品总数应该正确', () => {
    fc.assert(
      fc.property(fc.array(cartActionArbitrary, { maxLength: 20 }), (actions) => {
        const cart = new ShoppingCart();
        let expectedTotal = 0;

        actions.forEach(action => {
          try {
            if (action.type === 'add') {
              cart.addItem(action.item, action.quantity);
              expectedTotal += action.quantity;
            } else if (action.type === 'remove') {
              // Simulate remove logic to calculate expected total
              const existingItemIndex = cart.items.findIndex(i => i.item === action.item);
              if (existingItemIndex > -1) {
                const existingItem = cart.items[existingItemIndex];
                const removeQuantity = Math.min(existingItem.quantity, action.quantity);
                cart.removeItem(action.item, action.quantity);
                expectedTotal -= removeQuantity;
              }
            }
          } catch (e) {
            // Ignore errors during cart operations for simplicity, handle exceptions properly in a real scenario
            // For example, Quantity must be positive, which would break the assertion
          }
        });

        // Ensure expectedTotal is not negative
        expectedTotal = Math.max(0, expectedTotal);

        return cart.getTotalItems() === expectedTotal;
      })
    );
  });

  it('添加重复商品后,数量应该累加', () => {
    fc.assert(
      fc.property(itemArbitrary, quantityArbitrary, (item, quantity) => {
        const cart = new ShoppingCart();
        cart.addItem(item, quantity);
        cart.addItem(item, quantity);
        return cart.getTotalItems() === quantity * 2;
      })
    );
  });

  it('删除不存在的商品不应该报错', () => {
    fc.assert(
      fc.property(itemArbitrary, quantityArbitrary, (item, quantity) => {
        const cart = new ShoppingCart();
        cart.removeItem(item, quantity);
        return cart.getTotalItems() === 0;
      })
    );
  });
});

代码解释:

  • itemArbitrary: 生成随机的商品名称。
  • quantityArbitrary:生成随机的商品数量。
  • cartActionArbitrary:生成随机的购物车操作,可以是添加商品,也可以是删除商品。
  • fc.array(cartActionArbitrary, { maxLength: 20 }): 生成一个包含最多 20 个购物车操作的数组。
  • 第一个测试用例:模拟一系列的购物车操作,然后验证购物车中的商品总数是否正确。

一些建议

  • 从小处着手: 刚开始使用 Property-Based Testing 时,可以先从一些简单的函数或模块开始,逐步增加复杂度。
  • 多思考属性: 花时间思考代码应该满足哪些属性,这是 Property-Based Testing 的关键。
  • 结合单元测试: Property-Based Testing 并不是要取代单元测试,而是要和单元测试结合使用,才能达到最佳效果。
  • 关注 Shrinking:fast-check 发现一个反例时,它会自动尝试缩小这个反例,找到导致问题的最小用例。关注 Shrinking 的结果,可以帮助你更快地找到 bug 的根源。
  • 合理使用 Arbitrary: fast-check 提供了丰富的 Arbitrary,可以满足各种不同的测试需求。如果内置的 Arbitrary 不够用,可以自定义 Arbitrary。

总结

Property-Based Testing 是一种强大的测试方法,可以帮助你发现代码中隐藏的 bug,提高代码的健壮性。fast-check 是一个非常优秀的 JavaScript Property-Based Testing 库,值得你尝试。

希望今天的讲座对你有所帮助。下次再见!

发表回复

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