Skip to content
返回质量保障

属性测试

声明函数必须对所有输入遵守的定律,让框架生成数百随机输入试图证伪它。

为什么

基于示例的测试查你想到的用例;bug 藏在你没想到的用例里。属性测试反转负担:你声明必须始终成立的属性(排序返回相同元素不同顺序;解析再序列化返回原值;折扣永不为负),框架生成数百随机输入试图找反例。找到时它把反例缩小到最小失败用例,常是你从未考虑的边界(空字符串、负零、25 小时时区偏移)。它找到再多示例也到不了的 bug。

何时使用

用于有清晰定律的纯函数:解析器、序列化器、数学、排序、状态 reducer、数据变换。非副作用代码或 UI。函数有往返、逆或不变属性时加属性测试。

如何做

1. 选属性测试库
   - JS/TS: fast-check
   - Python: Hypothesis
   - Rust: proptest 或 quickcheck
   - Go: gopter

2. 确定一个属性(定律,非示例)
   - 往返: parse(serialize(x)) === x
   - 不变: sort(arr).length === arr.length
   - 幂等: normalize(normalize(x)) === normalize(x)
   - 单调: a > b → price(a) >= price(b)

3. 把属性写成对所有有效输入返回 true 的函数
   - fc.assert(fc.property(fc.array(fc.integer()), (arr) => {
       const sorted = sort(arr);
       return sorted.length === arr.length; // 不变量
     }))

4. 运行——框架默认生成 100+ 随机输入
   - 全过 → 属性成立(高概率)
   - 一个挂 → 框架缩小到最小反例

5. 读缩小反例修 bug 或修属性
   - 缩小常启发人:"对 [] 挂"或"对 [0, 0] 挂"
   - 若属性错了(太严)放宽它;若代码错了修它

示例

import { describe, it, expect } from "vitest";
import fc from "fast-check";
import { parseDuration, formatDuration } from "./duration";

describe("时长往返", () => {
  // 属性: 对任何有效时长,parse(format(d)) === d
  it("parse ∘ format = 恒等", () => {
    fc.assert(
      fc.property(fc.integer({ min: 0, max: 86400 }), (seconds) => {
        const formatted = formatDuration(seconds);
        const reparsed = parseDuration(formatted);
        expect(reparsed).toBe(seconds);
      }),
    );
  });

  // 属性: 格式化串永不含负数
  it("format 永不产生负分量", () => {
    fc.assert(
      fc.property(fc.integer({ min: 0, max: 86400 }), (seconds) => {
        const formatted = formatDuration(seconds);
        expect(formatted).not.toMatch(/-/);
      }),
    );
  });
});

// fast-check 发现: "对 3600 挂 → '1h0m0s' → parse 给 3600 ✓"
// 但若 formatDuration 对 0 有 bug: "对 0 挂 → '0h0m-0s'"
// 缩小反例精确定位边界用例。

常见问题

  • ⚠声明平凡为真的属性("返回字符串")给虚假信心——属性必须是若函数错则挂的有意义定律,非同义反复。
  • ⚠无约束生成输入(fc.integer() 无界)产生生产中函数永不见的值(百亿元素数组),测试因与真实使用无关的原因挂。
  • ⚠太严的属性("输出始终升序"对不排序的函数)老挂,开发者禁用它们而非修属性声明。
  • ⚠忽略缩小反例只重跑到随机通过浪费属性测试最有价值的输出——缩小就是 bug,最小陈述。

最佳实践

  • ✓从往返属性开始(parse ∘ serialize = 恒等)——最易陈述且抓最多序列化 bug,且无需正确输出预言。
  • ✓把输入生成器约束到函数真实定义域(秒级时长用 fc.integer({ min: 0, max: 1000000 }))使失败反映真实输入非不可能的。
  • ✓属性挂时仔细读缩小反例——它是最小失败用例,常揭示你未考虑的边界(空、零、负、最大边界)。
  • ✓属性测试配几条示例测试:示例为读者记录具体期望行为,属性验证定律对所有输入成立。