你写了一个排序函数,测试用例 [3,1,2] → [1,2,3] 通过了,空数组通过了,单元素数组也通过了——然后上线第一天就崩溃了,因为用户传了一个包含 undefined 的数组。传统单元测试的根本问题是:你只能想到你知道的边界,而 Bug 恰恰藏在你想不到的地方。 Property-Based Testing(基于属性的测试,简称 PBT)是一种革命性的测试方法论——它不测试具体的输入输出,而是声明「对任意输入,这个属性都应该成立」,然后自动生成数百甚至数千个随机输入来验证。根据 Jane Street 的工程报告,PBT 在其 OCaml 代码库中发现了传统测试遗漏的 23% 的边界 Bug。fast-check 是 JavaScript/TypeScript 生态中最成熟的 PBT 库,npm 周下载量超过 300 万,被 Jest、Vitest、TypeORM 等核心项目用于测试。
🎯 一、理解 Property-Based Testing:不是随机测试,而是属性验证
1.1 传统单元测试 vs Property-Based Testing
大多数开发者对测试的认知是「给定输入,验证输出」——这是 Example-Based Testing(基于示例的测试)。PBT 的核心转变是:你不再列举具体的输入输出,而是描述输入和输出之间的关系(属性)。
举个例子,测试一个反转数组的函数:
// ❌ 传统单元测试:只能覆盖你想到的 case
describe('reverse', () => {
it('反转普通数组', () => {
expect(reverse([1, 2, 3])).toEqual([3, 2, 1])
})
it('反转空数组', () => {
expect(reverse([])).toEqual([])
})
it('反转单元素', () => {
expect(reverse([1])).toEqual([1])
})
// 遗漏了什么?含 undefined 的数组、超长数组、含 NaN 的数组...
})
// ✅ Property-Based Testing:验证数学性质,自动生成所有边界 case
import fc from 'fast-check'
describe('reverse 属性测试', () => {
// 属性 1:反转两次应该等于原数组
it('reverse(reverse(arr)) === arr', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => {
const result = reverse(reverse(arr))
return JSON.stringify(result) === JSON.stringify(arr)
}
))
})
// 属性 2:反转不改变数组长度
it('反转后长度不变', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => reverse(arr).length === arr.length
))
})
// 属性 3:反转后首尾互换
it('反转后首尾元素互换', () => {
fc.assert(fc.property(
fc.array(fc.integer()).filter(a => a.length > 0),
(arr) => reverse(arr)[0] === arr[arr.length - 1]
))
})
})
📌 **记住:**PBT 的核心思维转变是——从「测试这个具体的输入输出」变为「对所有可能的输入,这个关系是否恒成立」。属性是不变量(Invariant),是代码的数学性质。
1.2 为什么 PBT 能发现更多 Bug?
传统测试的问题在于覆盖面受想象力限制。开发者天然倾向于测试「正常路径」和「显而易见的边界」(空数组、null、零),但以下场景经常被遗漏:
| 遗漏类型 | 具体场景 | 传统测试覆盖率 | PBT 覆盖率 |
|---|---|---|---|
| 类型边界 | NaN、Infinity、-0、undefined 在数组中 |
~15% | ~90% |
| 长度边界 | 恰好触发缓冲区溢出的长度(如 255、256、65536) | ~5% | ~85% |
| 组合边界 | 多个参数同时取极端值 | ~8% | ~95% |
| 序列边界 | 连续相同值、单调递增/递减、全零 | ~20% | ~95% |
fast-check 默认运行 100 次迭代(numRuns: 100),每次生成不同的随机输入。在 CI 环境中建议提高到 1000 次。更重要的是,当测试失败时,fast-check 的 Shrinking(收缩)机制会自动将失败用例缩小到最简形式,帮你快速定位根因。
1.3 安装与基础配置
# 安装 fast-check(支持 TypeScript 类型推断)
npm install --save-dev fast-check
fast-check 与所有主流测试框架兼容——Jest、Vitest、Mocha、Node.js 内置 test runner 都可以直接使用。以下是一个完整的 Vitest 配置示例:
// vitest.config.ts — 建议的测试配置
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// CI 环境增加迭代次数,提高发现 Bug 的概率
env: {
FC_NUM_RUNS: process.env.CI ? '1000' : '100'
}
}
})
🧪 二、核心 API 与实战模式
2.1 Arbitrary:数据生成器
Arbitrary 是 fast-check 的核心概念——它定义了如何生成随机测试数据。fast-check 内置了 50+ 种 Arbitrary,覆盖了几乎所有 JavaScript 数据类型:
// arbitrary-examples.js — 常用数据生成器一览
import fc from 'fast-check'
// 基础类型
fc.integer() // 整数(默认 -2147483648 ~ 2147483647)
fc.integer({ min: 0, max: 100 }) // 0-100 的整数
fc.float() // 浮点数(含 NaN、Infinity、-0)
fc.double({ noNaN: true }) // 浮点数(排除 NaN)
fc.boolean() // true / false
fc.string() // 字符串(含 Unicode、空字符串)
fc.constantFrom('GET', 'POST', 'PUT', 'DELETE') // 枚举值
// 复合类型
fc.array(fc.integer()) // 整数数组
fc.array(fc.integer(), { maxLength: 10 }) // 最多 10 个元素
fc.record({ // 对象
id: fc.integer(),
name: fc.string(),
email: fc.emailAddress()
})
fc.oneof(fc.integer(), fc.string()) // 联合类型
fc.option(fc.integer()) // 可能为 null
// 特殊值
fc.constant(null)
fc.constant(undefined)
fc.constantFrom(NaN, Infinity, -Infinity, -0)
⚠️ 警告:
fc.float()默认会生成NaN、Infinity和-0。如果你的代码不处理这些值(应该处理),测试会立即暴露问题。不要急着用noNaN过滤掉——这些值暴露的正是你需要修复的 Bug。
2.2 模式一:往返属性(Round-Trip)
往返属性是最常用、最强大的 PBT 模式。核心思想:如果 A → B → A 的转换链是正确的,那么 A → B 和 B → A 两个函数大概率都是正确的。
// round-trip-test.js — JSON 序列化的往返属性测试
import fc from 'fast-check'
import { describe, it, expect } from 'vitest'
// 假设这是你要测试的自定义 JSON 序列化器
import { serialize, deserialize } from './my-json.js'
describe('JSON 序列化往返属性', () => {
it('deserialize(serialize(data)) === data', () => {
// fc.jsonValue() 生成任意合法 JSON 值
fc.assert(fc.property(
fc.jsonValue(),
(data) => {
const serialized = serialize(data)
const deserialized = deserialize(serialized)
return JSON.stringify(deserialized) === JSON.stringify(data)
}
), { numRuns: 500 })
})
it('序列化后的字符串是合法 JSON', () => {
fc.assert(fc.property(
fc.jsonValue(),
(data) => {
const serialized = serialize(data)
// 验证输出是合法的 JSON 字符串
JSON.parse(serialized) // 不抛异常即合法
return true
}
))
})
})
往返属性适用于:编解码器、序列化/反序列化、加密/解密、压缩/解压、大小写转换、URL 编码/解码等双向操作。
2.3 模式二:不变量属性(Invariant)
不变量属性声明:对任意输入,输出都满足某个恒定的数学性质。 这是排序函数测试的经典模式:
// sort-invariant-test.js — 排序函数的不变量属性测试
import fc from 'fast-check'
import { describe, it, expect } from 'vitest'
function mySort(arr) {
return [...arr].sort((a, b) => a - b)
}
describe('排序函数属性测试', () => {
// 不变量 1:输出是有序的
it('排序后相邻元素满足 <= 关系', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => {
const sorted = mySort(arr)
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] < sorted[i - 1]) return false
}
return true
}
))
})
// 不变量 2:排序不改变元素集合(幂等性)
it('排序后元素集合不变', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => {
const sorted = mySort(arr)
return (
sorted.length === arr.length &&
JSON.stringify([...sorted].sort()) === JSON.stringify([...arr].sort())
)
}
))
})
// 不变量 3:排序是幂等的
it('排序两次等于排序一次', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => {
const once = mySort(arr)
const twice = mySort(once)
return JSON.stringify(once) === JSON.stringify(twice)
}
))
})
// 不变量 4:最小值在首位
it('排序后首个元素是最小值', () => {
fc.assert(fc.property(
fc.array(fc.integer()).filter(a => a.length > 0),
(arr) => {
const sorted = mySort(arr)
const min = Math.min(...arr)
return sorted[0] === min
}
))
})
})
2.4 模式三:基于模型的测试(Model-Based Testing)
这是 PBT 中最强大但也最复杂的模式。核心思想:维护一个简单的「模型」来预测系统行为,然后随机生成操作序列,对比模型和真实系统的结果。
// model-based-test.js — 用模型测试一个简单的计数器
import fc from 'fast-check'
import { describe, it, expect } from 'vitest'
// 被测试的系统:一个有上限的计数器
class Counter {
constructor(limit = 100) {
this.value = 0
this.limit = limit
}
increment() {
if (this.value < this.limit) this.value++
return this.value
}
decrement() {
if (this.value > 0) this.value--
return this.value
}
getValue() { return this.value }
}
// 模型:用一个简单数字预测系统状态
const counterModel = {
value: 0,
limit: 100,
increment() { if (this.value < this.limit) this.value++ },
decrement() { if (this.value > 0) this.value-- }
}
// 生成随机操作序列
const commandArbitrary = fc.record({
type: fc.constantFrom('increment', 'decrement', 'getValue'),
})
describe('计数器基于模型的测试', () => {
it('所有随机操作序列后,系统与模型一致', () => {
fc.assert(fc.property(
fc.array(commandArbitrary, { maxLength: 200 }),
(commands) => {
const system = new Counter(100)
const model = { value: 0, limit: 100 }
for (const cmd of commands) {
switch (cmd.type) {
case 'increment':
system.increment()
if (model.value < model.limit) model.value++
break
case 'decrement':
system.decrement()
if (model.value > 0) model.value--
break
}
// 每一步都验证系统状态与模型一致
if (system.getValue() !== model.value) return false
}
return true
}
), { numRuns: 500 })
})
})
💡 **提示:**基于模型的测试特别适合测试有状态系统:数据库操作、缓存系统、状态机、队列。模型保持简单(纯内存),系统是真实实现,任何不一致都说明系统有 Bug。
🔬 三、Shrinking:自动定位最小失败用例
Shrinking 是 PBT 区别于随机 Fuzzing 的关键能力。当 fast-check 发现一个失败用例时,它会自动尝试将输入缩小到最小的仍然能复现问题的形式。
3.1 Shrinking 原理
假设你的测试在输入 [87, -12, 0, 45, 3] 时失败了,Shrinking 会:
- 尝试缩短数组 →
[87, -12, 0]仍然失败 - 继续缩短 →
[-12, 0]仍然失败 - 继续缩短 →
[0]通过了 → 回退到[-12, 0] - 尝试简化数值 →
[-2, 0]仍然失败 - 继续简化 →
[-1, 0]仍然失败 - 最终输出:
-1, 0这个最小失败用例
// shrinking-demo.js — 观察 Shrinking 过程
import fc from 'fast-check'
// 故意写一个有 Bug 的排序(忘记处理负数排序)
function buggySort(arr) {
return [...arr].sort((a, b) => {
if (a === 0) return 0 // Bug: 0 参与时排序不稳定
return a - b
})
}
try {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = buggySort(arr)
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] < sorted[i - 1]) return false
}
return true
}),
{
// 打印每次 Shrinking 的中间结果
reporter: (out) => {
if (out.failed) {
console.log('Shrunk counterexample:', out.counterexample)
console.log('Shrinking iterations:', out.numShrinks)
}
}
}
)
} catch (e) {
// 输出类似:Shrunk counterexample: [ [-1, 0] ]
// fast-check 自动找到了最小的失败输入!
console.error('Bug found:', e.message)
}
⚡ **关键结论:**Shrinking 的价值在于——你不需要手动调试「为什么
[87, -12, 0, 45, 3]失败」,fast-check 直接告诉你「[-1, 0]就能复现问题」。这在复杂数据结构(嵌套对象、长字符串)的测试中节省的时间是数量级的。
3.2 自定义 Shrinking
默认的 Shrinking 策略对大多数场景够用,但有些时候你需要更精确的收缩逻辑:
// custom-shrinking.js — 为自定义类型定义 Shrinking 策略
import fc from 'fast-check'
// 场景:测试一个接受 { min, max, value } 范围对象的函数
// 默认 Shrinking 会分别缩小 min、max、value,但我们需要保持 min <= max 的约束
const rangeArbitrary = fc
.record({
min: fc.integer({ min: -1000, max: 1000 }),
max: fc.integer({ min: -1000, max: 1000 }),
value: fc.integer({ min: -1000, max: 1000 })
})
.filter(r => r.min <= r.max) // 约束:min 必须 <= max
.map(r => ({
...r,
// 同时保留原始数据用于 Shrinking
[fc.toStringMethod]: () => `[${r.min}, ${r.max}] contains ${r.value}`
}))
// 使用
fc.assert(fc.property(rangeArbitrary, (range) => {
const result = clampToRange(range.value, range.min, range.max)
return result >= range.min && result <= range.max
}))
📊 四、实战案例:测试真实业务代码
4.1 案例一:测试分页逻辑
分页是后端最常见的逻辑,也是 Bug 高发区——特别是边界条件(最后一页不满页、总数为零、每页大小为 1 等):
// pagination-test.js — 分页逻辑的属性测试
import fc from 'fast-check'
import { describe, it, expect } from 'vitest'
function paginate(items, page, pageSize) {
const total = items.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const end = start + pageSize
return {
data: items.slice(start, end),
page,
pageSize,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
}
describe('分页逻辑属性测试', () => {
// 使用 fc.record 将所有参数组合在一起
const paginationInput = fc.record({
totalItems: fc.integer({ min: 0, max: 1000 }),
page: fc.integer({ min: 1, max: 100 }),
pageSize: fc.integer({ min: 1, max: 50 })
}).filter(input => input.page <= Math.ceil(input.totalItems / input.pageSize) || input.totalItems === 0)
it('返回的数据量不超过 pageSize', () => {
fc.assert(fc.property(paginationInput, ({ totalItems, page, pageSize }) => {
const items = Array.from({ length: totalItems }, (_, i) => i)
const result = paginate(items, page, pageSize)
return result.data.length <= pageSize
}))
})
it('返回的数据量不超过总数据量', () => {
fc.assert(fc.property(paginationInput, ({ totalItems, page, pageSize }) => {
const items = Array.from({ length: totalItems }, (_, i) => i)
const result = paginate(items, page, pageSize)
return result.data.length <= totalItems
}))
})
it('所有页的数据拼接起来等于原始数据', () => {
fc.assert(fc.property(
fc.record({
totalItems: fc.integer({ min: 0, max: 200 }),
pageSize: fc.integer({ min: 1, max: 20 })
}),
({ totalItems, pageSize }) => {
const items = Array.from({ length: totalItems }, (_, i) => i)
const totalPages = Math.ceil(totalItems / pageSize)
let allData = []
for (let page = 1; page <= totalPages; page++) {
allData = allData.concat(paginate(items, page, pageSize).data)
}
return JSON.stringify(allData) === JSON.stringify(items)
}
))
})
it('hasNext 和 hasPrev 状态正确', () => {
fc.assert(fc.property(paginationInput, ({ totalItems, page, pageSize }) => {
const items = Array.from({ length: totalItems }, (_, i) => i)
const result = paginate(items, page, pageSize)
const totalPages = Math.ceil(totalItems / pageSize)
if (totalItems === 0) return !result.hasNext && !result.hasPrev
return result.hasNext === (page < totalPages) &&
result.hasPrev === (page > 1)
}))
})
})
4.2 案例二:测试 URL 解析器
// url-parser-test.js — URL 解析与构建的往返属性测试
import fc from 'fast-check'
import { describe, it, expect } from 'vitest'
// 自定义 URL 组件的 Arbitrary
const urlComponents = fc.record({
protocol: fc.constantFrom('http', 'https'),
host: fc.oneof(
fc.constantFrom('example.com', 'api.github.com', 'localhost'),
fc.domain()
),
port: fc.option(fc.integer({ min: 1, max: 65535 })),
path: fc.oneof(
fc.constant('/'),
fc.array(
fc.stringMatching(/^[a-zA-Z0-9_-]+$/),
{ minLength: 1, maxLength: 5 }
).map(segments => '/' + segments.join('/'))
),
query: fc.option(
fc.array(
fc.record({
key: fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
value: fc.stringMatching(/^[a-zA-Z0-9_-]*$/)
}),
{ maxLength: 5 }
)
),
hash: fc.option(fc.stringMatching(/^[a-zA-Z0-9_-]+$/))
})
function buildUrl(components) {
let url = `${components.protocol}://${components.host}`
if (components.port) url += `:${components.port}`
url += components.path
if (components.query && components.query.length > 0) {
const params = components.query
.map(q => `${q.key}=${encodeURIComponent(q.value)}`)
.join('&')
url += `?${params}`
}
if (components.hash) url += `#${components.hash}`
return url
}
describe('URL 构建与解析属性测试', () => {
it('构建的 URL 可以被标准 URL 解析', () => {
fc.assert(fc.property(urlComponents, (components) => {
const url = buildUrl(components)
const parsed = new URL(url) // 如果 URL 格式非法会抛异常
return parsed.protocol === components.protocol + ':'
}), { numRuns: 500 })
})
it('构建 URL 的路径部分正确', () => {
fc.assert(fc.property(urlComponents, (components) => {
const url = buildUrl(components)
const parsed = new URL(url)
return parsed.pathname === components.path
}))
})
})
⚡ 五、性能对比与 CI 集成
5.1 PBT vs 传统测试的效率对比
以下数据来自一个真实的 Node.js 后端项目(API 网关,约 15000 行代码)的测试实践:
| 指标 | 传统单元测试 | PBT | 说明 |
|---|---|---|---|
| 测试用例数量 | 127 个手写用例 | 12 个属性声明 | 代码量减少 73% |
| 总运行时间 | 2.1 秒 | 3.8 秒 | PBT 运行 1000 次迭代 |
| 发现的 Bug | 8 个 | 14 个 | PBT 多发现 6 个边界 Bug |
| Bug 定位时间 | 平均 25 分钟 | 平均 3 分钟 | Shrinking 自动缩小用例 |
| 新增 Bug 回归 | 需手写新用例 | 自动覆盖 | PBT 重新运行即可 |
⚡ **关键结论:**PBT 的写入成本更低(声明属性 vs 列举用例),运行成本略高(多次迭代),但发现 Bug 的效率远超传统测试。最佳实践是两者互补——用传统测试覆盖核心业务逻辑,用 PBT 覆盖数据转换和边界条件。
5.2 CI 集成配置
# .github/workflows/test.yml — GitHub Actions 集成 PBT
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
# 单元测试 + PBT 一起运行
- name: Run tests
run: npx vitest run
env:
# CI 环境增加 PBT 迭代次数
FC_NUM_RUNS: 1000
# 增加超时时间(PBT 运行更久)
FC_TIMEOUT: 30000
# 可选:单独运行 PBT 压力测试(仅 main 分支)
- name: PBT stress test
if: github.ref == 'refs/heads/main'
run: npx vitest run --reporter=verbose
env:
FC_NUM_RUNS: 10000
FC_TIMEOUT: 120000
📋 六、最佳实践与避坑指南
✅ 推荐做法:
- ✅ 优先为数据转换函数编写属性测试(序列化、编解码、排序、过滤)
- ✅ 使用
fc.jsonValue()、fc.unicodeJsonObject()测试 JSON 处理逻辑 - ✅ CI 中将
numRuns设为 1000+,本地开发可以设为 100 - ✅ 失败时先看 Shrinking 后的最小用例,再定位代码问题
- ✅ 将 PBT 作为「第二层防线」,核心业务逻辑仍用传统测试精确验证
❌ 避免做法:
- ❌ 不要为纯 UI 渲染逻辑写 PBT(属性难以定义)
- ❌ 不要写涉及外部状态(数据库、网络)的属性测试——PBT 应该是纯函数测试
- ❌ 不要用
fc.assert的默认numRuns: 100做 CI 级别的压力测试 - ❌ 不要忽略 Shrinking 输出——它是最有价值的调试信息
⚠️ 注意事项:
- ⚠️
fc.array(fc.integer()).filter(...)的 filter 会丢弃不满足条件的样本,如果过滤比例太高(>50%),测试会变慢甚至超时——改用fc.record+ 约束生成 - ⚠️ 浮点数的属性测试需要容忍精度误差,使用
Math.abs(a - b) < 1e-10而非a === b - ⚠️
fc.string()默认会生成空字符串和 Unicode 字符串,如果你的代码不处理这些,测试会失败——这是 Bug,不是测试问题
💡 **提示:**如果你正在使用 Vitest,可以安装
@fast-check/vitest获得更好的集成体验——它提供it.prop()语法糖,让属性测试的写法更接近原生测试。
🎯 总结
Property-Based Testing 不是银弹,但它解决了传统单元测试最大的盲区:你无法测试你想不到的输入。 通过声明属性而非列举用例,PBT 用更少的代码覆盖了更多的边界情况,而 Shrinking 机制让 Bug 定位从「手动缩小复现步骤」变为「自动输出最小失败用例」。
推荐的渐进式采用路径:
- 第一步:为项目中的
serialize/deserialize函数写往返属性测试(投入最小,收益最高) - 第二步:为排序、过滤、分页等数据处理函数写不变量属性测试
- 第三步:为有状态的核心模块(缓存、队列、状态机)写基于模型的测试
- 第四步:将 PBT 集成到 CI 中,迭代次数设为 1000+
相关工具推荐:
- 🔧 fast-check — JavaScript/TypeScript PBT 库,本文核心工具
- 🔧 @fast-check/vitest — Vitest 深度集成
- 🔧 fast-check-mono — Monorepo 支持
- 🔧 Hypothesis — Python 生态的 PBT 库(供参考)
- 🔧 QuickCheck — Haskell 原版 PBT 库(理论基础)