- 开发工具
- 测试
【免费下载链接】fast-check
Property based testing framework for JavaScript (like QuickCheck) written in TypeScript
本文以 fast-check 官方博客系列Advent of PBT 2024的第一天挑战为实战案例,讲解如何针对黑盒排序算法编写属性测试(Property-Based Testing),通过随机化输入与收缩(shrinking)机制定位并复现潜在缺陷。读完本文,你将掌握fc.property、fc.array、fc.nat、fc.stringMatching等核心构件的组合用法,并能独立复现 Day 1 的完整解题过程与实现源码细节。
挑战背景:Santa 的信件排序系统
圣诞老人每年都会收到来自全球孩子和大人的海量信件,每封信都带有独一无二的愿望清单与留言。为了在大日子前及时回复,圣诞老人今年决定按以下规则给回信排序:
- 发件人越年轻,回复越快(按年龄升序排列);
- 年龄相同时,按名字使用
<操作符做字典序排序。
输入被定义为一个信件数组,每封信是一个形如name=age的字符串:name由小写字母(a-z)构成、至少一个字符;age为7到77之间的整数。精灵们被要求返回一个按上述规则排序后的新数组。
小精灵们按期交付了实现,但圣诞老人担心其中暗藏缺陷——他无法直接看到实现源码(黑盒),于是寄希望于 fast-check 的属性测试能力:找出能让精灵实现崩溃或产生错误输出的一组输入。这正是 Day 1 挑战页 的核心任务。
先看懂「黑盒」:从源码反推缺陷机理
虽然挑战设计为黑盒(不直接展示实现),但仓库中其实保留了精灵们交付的源码文件 buggy.mjs,便于我们事后验证推理过程:
// @ts-check /** @typedef {{name: string; age: number;}} Letter */ /** * @param {Letter[]} letters * @returns {Letter[]} */ export default function sortLetters(letters) { const clonedLetters = [...letters]; return clonedLetters.sort( (la, lb) => la.age - lb.age || (la.name.codePointAt(0) ?? 0) - (lb.name.codePointAt(0) ?? 0), ); }而挑战页通过 AdventOfTheDay.tsx 注入了参考实现(reference implementation),用于比对行为差异:
type Letter = { name: string; age: number }; function sortLetters(letters: Letter[]): Letter[] { const clonedLetters = [...letters]; return clonedLetters.sort((la, lb) => la.age !== lb.age ? la.age - lb.age : la.name > lb.name ? 1 : la.name < lb.name ? -1 : 0, ); }两相对照,缺陷立即显形:
| 实现 | 年龄比较 | 同名比较 |
|---|---|---|
| 参考实现 | la.age !== lb.age ? la.age - lb.age : … | 完整三态比较(>/</ 相等返回 0) |
| 精灵实现(buggy) | la.age - lb.age \|\| … | 仅用codePointAt(0)比较首字符 |
两个关键缺陷:
- 同名字母序不完整:精灵实现只比较名字的第一个字符(
la.name.codePointAt(0) - lb.name.codePointAt(0)),而非像<运算符那样做完整字典序比较。例如alice与adam首字符相同但后续字符不同,该实现会把它们误判为“相等”。 codePointAt(0)返回undefined时的兜底值:代码里?? 0意味着当名字为空字符串时会按0参与比较。虽然题目约束“名字至少一个字符”规避了空名,但这也暴露出实现者对该 API 边界情况的处理方式。
用 fast-check 构造反例:核心属性测试代码
挑战页的交互式答题面板要求参与者手工构造反例输入并提交验证,其输入格式由 AdventOfTheDay.tsx 中的解析器定义:
const lineRegex = /^([a-z]+)=(\d+)$/; function parser(answer: string): unknown[] | undefined { const parsedAnswer: Letter[] = []; for (const line of answer.split('\n')) { const m = lineRegex.exec(line); if (m === null) { throw new Error( 'Each line of the answer should follow the pattern: name=age, …', ); } parsedAnswer.push({ name: m[1], age: Number(m[2]) }); } return [parsedAnswer]; }也就是说,答案形如anne=15(每行一封信),且要求age在7到77之间。这正是 fast-check 展示其价值的典型场景:与其靠直觉手写一组输入,不如用属性测试自动搜索反例。下面是用 fast-check 编写的解题属性:
import * as fc from 'fast-check'; import { sortLetters } from './advent.js'; // 被测实现 // 定义“合法信件”生成器:名字 = 小写字母串,年龄 ∈ [7, 77] const letterArbitrary = fc.record({ name: fc.stringMatching(/^[a-z]+$/), age: fc.integer({ min: 7, max: 77 }), }); // 属性 1:排序结果必须保持“年龄优先、同年龄按完整字典序” // 通过对照参考实现(差分测试)验证黑盒行为 fc.assert( fc.property(fc.array(letterArbitrary), (letters) => { const actual = sortLetters(letters); const expected = referenceSort(letters); // 参考实现 // 逐项断言结果一致(可用 lodash 的 isEqual) // 若两实现输出不同,则说明发现反例 }), );关键构件说明(均来自仓库官方文档):
fc.property:声明“要验证什么(谓词)+ 如何生成谓词输入(arbitraries)”的最小单元,支持同步与异步谓词;fc.array:生成数组,可传{ minLength?, maxLength?, size? }控制长度分布,默认minLength: 0、maxLength: 0x7fffffff;fc.stringMatching:生成匹配给定正则的字符串,这里是约束name为纯小写字母串的精确手段;fc.integer:在闭区间[min, max]内生成有符号整数,天然满足age ∈ [7, 77]的取值约束;fc.record:组合出{ name, age }结构的信件对象。
当属性断言失败时,fc.assert(runner)会自动尝试收缩(shrinking)——把导致失败的最小输入逐步缩减为人类可读的最简单反例,这是 fast-check 相较纯随机测试的核心优势(见 getting-started 文档 对 runner 的说明)。
反例剖析:一个反例如何击穿精灵实现
结合上文源码分析,攻击点显而易见:只需构造两个年龄相同、名字首字符相同但完整字典序不同的信件,例如:
alice=15 adam=15- 按题目规则(
<运算符),adam < alice,正确顺序应为adam在前; - 但精灵实现用
codePointAt(0)比较,'a' - 'a' === 0,两封信被判为“相等”,排序结果取决于Array.prototype.sort的稳定性,alice可能仍然排在adam前面,从而输出错误顺序。
同理,bob=30与ben=30、carol=40与carl=40都能稳定击穿该实现。在 Day 1 挑战的答题表单中提交形如alice=15\nadam=15的输入即可验证:参考实现与精灵实现的输出不一致,代表解题成功。
这正是属性测试的威力:差分测试(differential testing)——拿一个可信参考实现与黑盒实现做输入输出比对——能在不知道实现细节的情况下自动发现语义偏差。
从挑战到工程实践:排序类算法可复用的属性集
Day 1 的题目本质上是对一个排序函数的黑盒验证。仓库中的 examples/001-simple/sort 提供了一个不依赖参考实现、直接面向排序语义的完整属性集范本,可作为同类场景的通用模板:
import { describe, it, expect } from 'vitest'; import * as fc from 'fast-check'; import _ from 'lodash'; import { sort } from './src/sort.js'; describe('sort', () => { it('should have the same length as source', async () => { await fc.assert( fc.property(fc.array(fc.integer()), (data) => { expect(sort(data)).toHaveLength(data.length); }), ); }); it('should have exactly the same number of occurences as source for each item', async () => { await fc.assert( fc.property(fc.array(fc.integer()), (data) => { const sorted = sort(data); expect(_.groupBy(sorted)).toEqual(_.groupBy(data)); }), ); }); it('should produce an ordered array', async () => { await fc.assert( fc.property(fc.array(fc.integer()), (data) => { const sorted = sort(data); for (let idx = 1; idx < sorted.length; ++idx) { expect(sorted[idx - 1]).toBeLessThanOrEqual(sorted[idx]); } }), ); }); });这套属性集覆盖了排序算法的三条不变量,完全适用于 Day 1 场景(只需把比较器换成“年龄优先、同名完整字典序”):
- 长度不变:排序不增不减元素;
- 多重集合不变:每个元素出现的次数与输入一致(用
groupBy验证,防止元素被替换或丢失); - 有序性:按指定比较器,相邻元素满足顺序关系。
其中第 2、3 条对 Day 1 尤其致命:精灵实现会“吞掉”同首字符名字间的次序差异,一旦属性 3 使用题目定义的完整字典序比较器,反例就会被自动搜出并收缩到最小规模。
挑战页工程解析:黑盒答题如何运作
如果你好奇挑战页“黑盒 + 表单提交”的机制,仓库源码给出了完整答案。整个 Day 1 页面由三个组件协作:
- AdventOfTheDay.tsx:把精灵实现(
buggyAdvent)、参考实现(referenceAdvent)与输入解析器组装成可交互的 Playground 与答题表单; - AdventOfTheDayBuilder.tsx:核心比对逻辑所在。提交答案后,
onSubmit会调用isEqual(postAdvent(buggyFn(...inputs)), postAdvent(referenceAdvent(...inputs)))——若两者输出相等,则提示“你提供的输入看起来工作正常,圣诞老人在找 bug!”;若不等,则判定反例有效并存入localStorage(键为aopbt24-day1),下次访问自动回填; - AdventPlayground.tsx:通过 StackBlitz SDK 动态嵌入一个包含
advent.js、advent.d.ts、advent.test.ts与package.json(依赖fast-check、vitest)的 Node 模板项目,让读者在浏览器里直接跑npx vitest --watch进行属性测试实验;同时提供本地下载入口,便于离线复现。
这套机制本身就是对属性测试工作流的一个绝佳示例:被测对象隔离(黑盒)+ 参考实现对照 + 可复现的最小输入,与我们在“反例剖析”一节做的事完全一致。
小结:从一道圣诞谜题到可复用的测试方法论
Advent of PBT Day 1 表面上是一道节日谜题,内核却浓缩了属性测试的三个关键实践:
- 用差分测试攻破黑盒:拿参考实现做输入输出对照,无需阅读源码即可定位语义偏差;
- 让生成器忠实于规格:用
fc.stringMatching(/^[a-z]+$/)与fc.integer({ min: 7, max: 77 })把输入空间精确约束在题目定义域内,避免生成无效数据; - 依赖收缩定位最小反例:
fc.assert在断言失败时自动把反例缩减到最易读的形式(如[{name:'alice',age:15},{name:'adam',age:15}]),让修复者一眼看清根因。
如果你想继续深入,可以顺着仓库线索依次阅读:Day 1 挑战页、精灵缺陷实现、参考实现与输入解析,以及官方文档中的 属性与 runner 入门 和 arbitraries 总览。祝你圣诞快乐,也祝你早日用属性测试驯服所有黑盒算法。
- 开发工具
- 测试
【免费下载链接】fast-check
Property based testing framework for JavaScript (like QuickCheck) written in TypeScript
相关推荐
TypeScript 联合类型实战:TypeHero Advent of TypeScript 2024 Day 1(Christmas Cookies)题解与题目机制剖析
TypeScript 联合类型实战:TypeHero Advent of TypeScript 2024 Day 1(Christmas Cookies)题解与
教育前端后端用 TypeScript 类型系统实现倒计时:TypeHero「Advent of TypeScript 2023」Day 13 的 DayCounter 实战解析
用 TypeScript 类型系统实现倒计时:TypeHero「Advent of TypeScript 2023」Day 13 的 DayCounter 实战
教育前端后端TypeHero 题解:用 TypeScript 变长元组类型实现 `SantasList`(Advent of TypeScript 2024 Day 5)
TypeHero 题解:用 TypeScript 变长元组类型实现 SantasList (Advent of TypeScript 2024 Day 5) 本
教育前端后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考