FsCheck:如何在单个父生成器上下文中局部覆盖类型生成器
FsCheck生成器范围内局部覆写类型生成规则方案
问题场景
用FsCheck做属性测试时,经常需要生成复杂数据结构,但要求结构里的某个成员类型用特殊的差异化生成规则。
以如下泛型树类型为例:
type Tree<'LeafData,'INodeData> = | LeafNode of 'LeafData | InternalNode of 'INodeData * Tree<'LeafData,'INodeData> list
需要生成符合以下规则的测试用例:
- 不存在没有子节点的内部节点
- 不生成不符合约束的叶节点
- 叶节点取值仅来自预先限定的允许子集
直接覆写对应子类型的全局生成逻辑很容易实现需求,但FsCheck的register方法本质是线程级的全局操作,没有提供生成器范围内的局部覆写能力,很容易导致同线程下测试用例的生成逻辑互相串扰。
期望的实现形式参考如下伪代码:
let limitedLeafs = gen { let leafGen = Arb.generate<LeafType> |> Gen.filter isAllowedLeaf do! registerContextualArb (leafGen |> Arb.fromGen) return! Arb.generate<Tree<NodeType, LeafType>> }
通过调整类型定义、递归映射后过滤结果的方式可以绕过这个限制,但这类方案不具备普适性,实现复杂度也比较高。
实现方案
FsCheck没有内置上下文级别的Arb注册能力,实际使用中不需要强求类似局部注册的语法,直接显式组装生成器是更稳妥的方案,没有全局副作用,规则也完全可控。
方案1:显式构建递归生成器(推荐)
针对递归类型直接编写生成逻辑,可以精确控制每一层节点的生成规则,性能远优于先生成再过滤的方案:
let genCustomTree (allowedLeafGen: Gen<LeafType>) (internalNodeGen: Gen<NodeType>) = let rec genTree size = gen { // 尺寸归零时生成叶节点,避免无限递归 if size <= 0 then let! leafVal = allowedLeafGen return LeafNode leafVal else let! nodeVal = internalNodeGen // 强制内部节点至少持有1个子节点,杜绝空内部节点 let! childCount = Gen.choose(1, 6) // 可按需调整子节点数量范围 let! childSizes = Gen.listOfLength childCount (Gen.choose(0, size - 1)) let! children = Gen.traverse genTree childSizes return InternalNode (nodeVal, children) } Gen.sized genTree
调用时直接传入定制好的叶节点生成器即可,不需要修改任何全局注册状态:
let allowedLeafGen = Arb.generate<LeafType> |> Gen.filter isAllowedLeaf let limitedLeafsTreeGen = genCustomTree allowedLeafGen Arb.generate<NodeType>
方案2:对已有默认生成器做局部转换
如果不想手动编写递归生成逻辑,也可以对FsCheck默认生成的树做递归转换,替换非法节点:
let limitedLeafs = gen { let allowedLeafGen = Arb.generate<LeafType> |> Gen.filter isAllowedLeaf let! rawTree = Arb.generate<Tree<NodeType, LeafType>> // 递归清洗树结构,替换非法叶节点、移除空内部节点 let rec sanitizeTree = function | LeafNode _ -> allowedLeafGen |> Gen.map LeafNode | InternalNode (_, []) -> // 空内部节点直接替换为合法叶节点 allowedLeafGen |> Gen.map LeafNode | InternalNode (data, children) -> gen { let! sanitizedChildren = Gen.traverse sanitizeTree children return InternalNode (data, sanitizedChildren) } return! sanitizeTree rawTree }
注意事项
- 不要用
Arb.register实现局部定制,该方法会修改线程级的全局生成器字典,会导致同线程下其他测试用例意外套用定制逻辑,产生隐式的测试依赖,影响测试结果稳定性。 - 如果待过滤的非法值占比较高,优先使用显式递归生成器方案,避免
Gen.filter触发大量生成重试,拖慢测试执行速度。
内容的提问来源于stack exchange,提问作者farlee2121
相关产品推荐
相关产品推荐

