- 开发工具
- 测试
【免费下载链接】fast-check
Property based testing framework for JavaScript (like QuickCheck) written in TypeScript
本篇围绕 fast-check 4.4.0 版本的核心更新展开:新增的fc.map与fc.set两个 arbitrary、fc.dictionary对完整PropertyKey(字符串、数字、Symbol)键的支持。结合仓库中的源码实现与集成测试,你将理解这些生成器的约束参数如何工作、为何SameValueZero比较语义是它们与原生Map/Set保持一致的关键,以及底层“数组 + 映射器”架构如何支撑起完整的 shrink(收缩)能力。
该版本公告原文位于 4.4.0 发布公告,对应的完整变更清单可在 CHANGELOG 中查阅。
版本 4.4.0 带来了什么
4.4.0 是一次面向“集合类型”的功能发布,官方公告将其概括为三件事:
- 新增
fc.maparbitrary:专门用于生成 JavaScript 原生Map实例; - 新增
fc.setarbitrary:专门用于生成原生Set实例; fc.dictionary支持完整的PropertyKey:此前fc.dictionary的键只能是字符串,而现在可以生成以字符串、数字或 Symbol 为键的对象属性。
官方公告的原话是:这两个原生集合现在是 fast-check 中的“一等公民”(first-class citizens),让依赖它们的代码更容易被属性测试覆盖。
从 入口文件 可以确认,map、set及其约束类型MapConstraints、SetConstraints均已从主包导出:
export type { MapConstraints } from './arbitrary/map.js'; export { map } from './arbitrary/map.js'; // ... export type { SetConstraints } from './arbitrary/set.js'; export { set } from './arbitrary/set.js';新增fc.maparbitrary
基本用法与签名
fc.map的调用方式与fc.dictionary、fc.object类似,接收 key 和 value 两个 arbitrary:
// 生成字符串键、数字值的 Map fc.map(fc.string(), fc.nat()); // 带约束条件 fc.map(fc.string(), fc.nat(), { minKeys: 1, maxKeys: 10 });完整签名为:
fc.map(keyArb, valueArb) fc.map(keyArb, valueArb, { minKeys?, maxKeys?, size?, depthIdentifier? })各约束参数(定义于 MapConstraints):
| 参数 | 默认值 | 含义 |
|---|---|---|
keyArb | 必填 | 生成 Map 键的 arbitrary |
valueArb | 必填 | 生成 Map 值的 arbitrary |
minKeys? | 0 | 生成实例中条目数的下限(含) |
maxKeys? | 0x7fffffff | 生成实例中条目数的上限(含) |
size? | 全局默认(small) | 控制生成值“能有多大”,详见下文 size 机制 |
depthIdentifier? | undefined | 在多个实例间共享当前深度,避免递归结构过深 |
实现原理:uniqueArray + SameValueZero 去重
map.ts 的实现只有几行,但信息量很大:
export function map<K, V>( keyArb: Arbitrary<K>, valueArb: Arbitrary<V>, constraints: MapConstraints = {}, ): Arbitrary<Map<K, V>> { return uniqueArray(tuple(keyArb, valueArb), { minLength: constraints.minKeys, maxLength: constraints.maxKeys, size: constraints.size, selector: mapKeyExtractor, depthIdentifier: constraints.depthIdentifier, comparator: 'SameValueZero', }).map(arrayToMapMapper, arrayToMapUnmapper); }从中可以读出三个设计要点:
- 以
[key, value]元组数组为中间表示。fc.map并不是凭空生成 Map,而是先生成一个“键唯一”的元组数组(uniqueArray(tuple(keyArb, valueArb), ...)),再映射为原生Map。selector: mapKeyExtractor告诉uniqueArray按元组的第一个元素(即 key)做去重判定,这正好与Map本身“键唯一”的语义对齐——两个SameValueZero相等的键在 Map 中本就会被合并。 - 键的比较采用
comparator: 'SameValueZero'。这是Map内部使用的比较算法,因此生成器层面的“键唯一”与new Map(entries)构建时的行为完全一致,不会出现“数组里键看似重复、但 Map 里却保留了多个条目”的不一致。 - 注册了双向映射器。arrayToMapMapper / arrayToMapUnmapper 负责数组与
Map之间的转换:
export function arrayToMapMapper<TKey, TValue>(data: [TKey, TValue][]): Map<TKey, TValue> { return new Map(data); } export function arrayToMapUnmapper<TKey, TValue>(value: unknown): [TKey, TValue][] { if (typeof value !== 'object' || value === null) { throw new Error('Incompatible instance received: should be a non-null object'); } if (!('constructor' in value) || value.constructor !== Map) { throw new Error('Incompatible instance received: should be of exact type Map'); } return Array.from(value as Map<TKey, TValue>); }map/unmap成对注册是 fast-check 的关键机制:有了 unmap(逆变换),生成过的 Map 才能被 shrink 器重新展开为元组数组,进而收缩到更小的 Map。unmap 端严格校验constructor === Map,确保只有“确实由该 arbitrary 生产出来的值”才参与后续收缩,而不是深入检查值的内部。
测试如何验证 fc.map 的正确性
map.spec.ts 使用了一套“约束参数自己也是随机的”集成测试范式:
const extraParameters: fc.Arbitrary<Extra> = fc.record( { keys: fc.uniqueArray(fc.anything(), { minLength: 35, // Keys equal in terms of SameValueZero will be merged together in a Map. comparator: 'SameValueZero', }), values: fc.uniqueArray(fc.anything(), { minLength: 1 }), constraints: fc .tuple(fc.nat({ max: 5 }), fc.nat({ max: 30 }), fc.boolean(), fc.boolean()) .map(([min, gap, withMin, withMax]) => ({ minKeys: withMin ? min : undefined, maxKeys: withMax ? min + gap : undefined, })), }, { requiredKeys: ['keys', 'values'] }, );它覆盖三类断言:
assertProduceSameValueGivenSameSeed—— 同种子必得同值(可复现性);assertProduceCorrectValues—— 生成的Map中每个 key、value 都必须来自指定的候选集合(精确引用相等,NaN 单独判断),且minKeys/maxKeys约束被遵守;assertProduceValuesShrinkableWithoutContext—— 在底层 arbitrary 可收缩的前提下,生成的 Map 本身也被视为“可收缩”。
新增fc.setarbitrary
基本用法与签名
与fc.map同期引入的fc.set用于生成Set实例:
// 生成字符串集合 fc.set(fc.string()); // 带大小约束 fc.set(fc.nat(), { minLength: 2, maxLength: 20 });完整签名为:
fc.set(arb) fc.set(arb, { minLength?, maxLength?, size?, depthIdentifier? })各约束参数(定义于 SetConstraints):
| 参数 | 默认值 | 含义 |
|---|---|---|
arb | 必填 | 生成集合元素的 arbitrary |
minLength? | 0 | 集合元素数量下限(含) |
maxLength? | 0x7fffffff | 集合元素数量上限(含) |
size? | 全局默认 | 控制生成值规模 |
depthIdentifier? | undefined | 共享深度标识,约束递归深度 |
SameValueZero:与原生 Set 完全一致的相等语义
公告强调:“fc.set保证生成的集合中所有元素按Set的相等语义唯一”。这一点在 set.ts 中落实为对uniqueArray的调用:
export function set<T>(arb: Arbitrary<T>, constraints: SetConstraints = {}): Arbitrary<Set<T>> { return uniqueArray(arb, { minLength: constraints.minLength, maxLength: constraints.maxLength, size: constraints.size, depthIdentifier: constraints.depthIdentifier, comparator: 'SameValueZero', }).map(arrayToSetMapper, arrayToSetUnmapper); }SameValueZero与Object.is几乎相同,唯一区别是将-0与+0视为相等——这正是Set和Map使用的算法。其实际影响可以用一组边界值直观理解:
NaN与NaN相等(而===下不等),所以集合中至多一个NaN;-0与+0相等,所以集合中不会同时出现两者。
set.spec.ts 中专门有一条针对这些特殊数值的测试:
it('should handle special numeric values correctly (NaN, -0, +0)', async () => { await assertProduceCorrectValues( () => set(constantFrom(-0, 0, Number.NaN, 1, 2)), (s) => { // In SameValueZero, -0 and +0 are considered equal, so we shouldn't have both const zeroCount = Array.from(s).filter((v) => v === 0).length; return zeroCount <= 1; }, ); });转换映射器 ArrayToSet.ts 与 Map 版本同构:正向new Set(data),反向校验constructor === Set后Array.from展开,同样保证了 Set 的可收缩性。
另外需要留意源码注释中的一条边界说明:depthIdentifier目前只作用于“集合内部元素”的深度控制,不会反过来影响“本次要生成多少个元素”的决策(见 SetConstraints.depthIdentifier 的 JSDoc)。
fc.dictionary 支持完整 PropertyKey
变更前后对比
JavaScript 对象的属性键(PropertyKey)可以是字符串、数字或 Symbol,但 4.4.0 之前的fc.dictionary只接受Arbitrary<string>。4.4.0 起,它接受完整的PropertyKey范围。
这一变化在源码中体现为 dictionary.ts 的一组重载:
// 旧签名:字符串键,返回 Record<string, T>(自 1.0.0 起) export function dictionary<T>( keyArb: Arbitrary<string>, valueArb: Arbitrary<T>, constraints?: DictionaryConstraints, ): Arbitrary<Record<string, T>>; // 新签名:完整 PropertyKey 键,返回 Record<K, V>(自 4.4.0 起) export function dictionary<K extends PropertyKey, V>( keyArb: Arbitrary<K>, valueArb: Arbitrary<V>, constraints?: DictionaryConstraints, ): Arbitrary<Record<K, V>>;由于第一个重载依然保留,既有字符串键的调用方式与返回类型完全向后兼容;数字与 Symbol 键则走新重载,返回Record<K, V>。
实现细节:数字键的字符串化与__proto__的安全处理
运行时实现部分(第 85–101 行)与fc.map结构一致——uniqueArray(tuple(keyArb, valueArb), { selector: dictionaryKeyExtractor, ... })生成键唯一的键值对数组,再经keyValuePairsToObjectMapper构建对象。这里有两个值得注意的实现细节,都在 KeyValuePairsToObject.ts 中:
const obj: Record<K, V> = definition[1] ? Object.create(null) : {}; for (let idx = 0; idx !== keyValues.length; ++idx) { const key = keyValues[idx][0]; if (key === '__proto__') { Object.defineProperty(obj, key, { enumerable: true, configurable: true, writable: true, value: keyValues[idx][1], }); } else { obj[key] = keyValues[idx][1]; } }__proto__特殊分支:普通赋值obj['__proto__'] = v触发的是原型链设置而非定义自有属性;改用Object.defineProperty才能把__proto__作为真正的自有数据属性写入。对属性测试工具而言,这一处理确保了{'__proto__': value}这类“看起来危险”的输入被当作普通属性来生成,而不是悄悄改变对象的原型。- 数字键会被隐式转成字符串:这是 JS 语言语义(对象属性名始终是字符串或 Symbol)。dictionary.spec.ts 的测试中专门用注释和 selector 反映了这一点:
keys: fc.uniqueArray(fc.oneof(fc.string(), fc.nat(), fc.string().map(Symbol), { // Numbers will become strings when used in an object. selector: (key) => (typeof key === 'number' ? String(key) : key), }),该测试同时覆盖了三种键类型:fc.string()、fc.nat()、fc.string().map(Symbol),并验证minKeys/maxKeys/noNullPrototype约束均正确生效。
fc.dictionary的约束参数(DictionaryConstraints)在此版本中保持不变:minKeys(默认 0)、maxKeys(默认0x7fffffff)、size、depthIdentifier(3.15.0 起)、noNullPrototype(3.13.0 起,默认false;实现中通过noNullPrototype ? constant(false) : boolean()决定是否随机生成 null-prototype 对象)。
size 机制:三个 arbitrary 共享的规模控制
fc.map、fc.set、fc.dictionary都支持size参数,其语义集中在 MaxLengthFromMinLength.ts:
- Size 取值:
'xsmall' | 'small' | 'medium' | 'large' | 'xlarge',全局默认是small(DefaultSize);也支持相对值('-4'到'+4')与'max'; - 实际上限:
maxLength的统一上界是MaxLengthUpperBound = 0x7fffffff,即各约束表中maxKeys/maxLength的默认值; - 由 size 推导实际长度上限的公式(maxLengthFromMinLength):
xsmall → floor(1.1 × min) + 1 small → 2 × min + 10 medium → 11 × min + 100 large → 101 × min + 1000 xlarge → 1001 × min + 10000也就是说,不显式写maxLength时,生成规模由minLength(或minKeys)和size共同决定;显式写了maxKeys时结果还会与公式值取较小者。对fc.map/fc.set的常见实践是:键/值来自一个很小的有限集合(如fc.constantFrom('a','b','c'))时,把maxKeys设为可区分的键数上限,可以避免生成器反复尝试生成注定重复的条目。
depthIdentifier则是为递归结构准备的:相同标识的实例共享一份深度计数,集合内已生成的条目越多,后续条目越倾向“变浅”,从而避免结构在深度上失控膨胀。
4.4.0 的其余变更
官方 changelog(基于 4.3.0)除上述三项功能外还包含:
Features
fc.dictionary支持完整PropertyKey(PR#6232);- 新增
fc.map(PR#6267); fc.letrec的circular选项(PR#6040)——注意:同一版本内即被 PR#6334 以REVERT-6040撤销,即“letrec的自引用能力”在 4.4.0 中并未真正落地,阅读 changelog 时不要把这条当作可用特性;- 新增
fc.set(PR#6270)。
Fixes 与其他(摘要)
修复项大部分是 CI 与发布流水线层面的调整,例如:发布时强制 OTP、npm 发布增加 provenance attestation(PR#6184)、pnpm lockfile 合并冲突的自动解决工作流(PR#6248)、测试矩阵调整到 Node 24 并分片产生覆盖率(PR#6282/PR#6283)等。值得留意的代码层变更有两条:
- 性能(PR#6278):在性能关键路径上改用
Math.imul与位移运算; - 重构(PR#6311/PR#6318/PR#6320/PR#6321):
letrec实现抽取公共部分、惰性 arbitrary 构建逻辑抽出、LazyArbitrary显式空值检查等——这些是fc.map/fc.set落地前对集合类 arbitrary 基础设施的整理。
完整清单(含全部 PR 编号)见 4.4.0 公告 与 CHANGELOG。
小结与实践建议
- 被测代码以
Map为输入输出时,直接用fc.map(keyArb, valueArb, { minKeys, maxKeys })替代手搓fc.uniqueArray(...).map(new Map),键唯一性由SameValueZero保证且自带 shrink; fc.set对NaN、±0的去重行为与原生Set一致,测试涉及浮点边界值时这一点尤其重要;- 需要 Symbol 键或数字键的对象生成(如序列化器、缓存键映射的测试)现在可以直接交给
fc.dictionary,无需再通过fc.record之类的结构间接表达; - 键来自有限集合时,显式给出
maxKeys/maxLength能显著提升生成效率; - 以上 API 自 fast-check 4.4.0 引入,当前仓库中的主包版本(见 package.json 的
version字段)已经包含这些能力,官方文档中的签名与示例见 Object 组合类 arbitrary 文档。
- 开发工具
- 测试
【免费下载链接】fast-check
Property based testing framework for JavaScript (like QuickCheck) written in TypeScript
相关推荐
fast-check 3.13.0 新特性解析:date 生成 Invalid Date、dictionary/record 生成无原型对象
fast check 3.13.0 新特性解析:date 生成 Invalid Date、dictionary/record 生成无原型对象 fast chec
开发工具测试fast-check 3.21.0 新特性:uuid 任意值生成器支持自定义版本(v1–v15)
fast check 3.21.0 新特性:uuid 任意值生成器支持自定义版本(v1–v15) 本篇技术指南聚焦 fast check 3.21.0 版本对
开发工具测试@fast-check/vitest 演进全记录:从首个稳定版到 Vitest 5 与 fast-check 插件 API 的完整支持
@fast check/vitest 演进全记录:从首个稳定版到 Vitest 5 与 fast check 插件 API 的完整支持 @fast check/
开发工具测试
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考