属性测试失败后,怎么复现并看到最小反例?
CI 里一个属性测试红了,原始失败输入是 { x: 100, y: -1 },你本地再跑一遍,它可能又绿了,因为输入是随机生成的。
**用 Effect v4 的话,复现靠 seed 和 replay,看最小反例靠 shrinking:checkEffect 失败时返回的 Falsified 结果自带 shrunkInput 和 replay,把 replay 传回去就能重放同一个失败,不需要知道 seed。**shrinking 是结构化的,缩出来的值仍然满足 Schema。
这些都来自 effect/Arbitrary:它从 Schema 派生生成器,再由 checkEffect 跑属性,基本用法见 Effect v4 不用 fast-check,属性测试怎么写?。下面按失败之后你会问的顺序讲。
怎么让同一批输入再来一遍?
先看一个会失败的属性,以及怎么拿到复现信息:
import { Arbitrary, Effect, Schema } from "effect"
const Point = Schema.Struct({
x: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
y: Schema.Int
})
const arb = Arbitrary.schema(Point)
const program = Effect.gen(function* () {
// 取样:同一个 seed 得到同一批值
const a = yield* Arbitrary.sampleEffect(arb, { count: 3, seed: 42 })
const b = yield* Arbitrary.sampleEffect(arb, { count: 3, seed: 42 })
console.log(a) // [ { x: 98, y: 1 }, { x: 76, y: -84 }, { x: 83, y: 6 } ]
console.log(JSON.stringify(a) === JSON.stringify(b)) // true
// 检查属性:故意写一个错的
const result = yield* Arbitrary.checkEffect(arb, (p) => p.x + p.y < 50, { runs: 100, seed: 1 })
if (result._tag === "Falsified") {
console.log(result.initialInput) // { x: 100, y: -1 }
console.log(result.shrunkInput) // { x: 51, y: 0 }
console.log(result.replay) // [0,"1",1,0,[2,0,0,0,0,0,1],"ReturnedFalse"]
console.log(Arbitrary.formatCheckFailure(result))
// 用 replay 复现同一个失败,不需要知道 seed
const again = yield* Arbitrary.checkEffect(arb, (p) => p.x + p.y < 50, { replay: result.replay })
console.log(again._tag) // Falsified
}
})
Effect.runPromise(program)seed 固定随机种子,同一个 seed 在同一实现下得到同一批输入。没传 seed 时,种子由 Effect 的 Random 服务选取;失败结果里的 replay(或 Exhausted 里的 seed)会记下实际用到的值。
replay 里到底记了什么?
replay 是 Falsified 结果自带的一个值,记录了种子、第几次尝试、大小以及完整的 shrink 路径。传给 checkEffect 的 replay 选项后,其它的 runs、seed、size 选项都会被忽略,直接重放那一次失败。
所以流程很直接:CI 里失败了,把日志里的 replay 贴到本地就能复现,比「再跑一遍看看」可靠得多。如果 token 和当前实现对不上,checkEffect 返回的是 ReplayMismatch,而不是误报通过。
shrinking 是怎么缩小反例的?
随机找到的失败输入往往又长又乱,需要缩成人能读的最小反例。这里的 shrink 是结构化的:Schema 的每一层(对象字段、数组元素、联合分支)都知道怎么变小,而且每个候选都要重新通过 Schema 的检查才会交给你的属性,所以缩小过程不会产出违反约束的值。
上面那次失败从 { x: 100, y: -1 } 缩到了 { x: 51, y: 0 },7 步。注意它不保证全局最小(这里 { x: 50, y: 0 } 才是),maxShrinks 限定了预算,用完就返回当前找到的最好结果。
默认的缩法不合适,怎么自己定义?
Arbitrary.schema(schema, { shrink }) 的回调返回「一步可达的更简单候选」,它会整体替换由 Schema 推导的 shrink,且必须同步、确定、会终止。我试过 shrink: (n) => n > 0 ? [n - 1] : [],对属性 n < 5 从 6 缩到 5,正好是最小反例。
因为是整体替换,原本由 Schema 保证的「候选仍满足约束」这一层,要靠你自己写的回调来保证。
replay 能当长期存档吗?
不能。重放 token 的文档明确写了跨版本不保证兼容,整个 Arbitrary 模块在 JSDoc 里也标注为 @stability unstable。拿它在同一次 CI 失败和本地复现之间传递没问题,不要把它存成长期的回归用例;要固化一个回归,把 shrunkInput 写成普通的 example 测试。
版本是 effect 4.0.5。取样、checkEffect的Falsified结果、replay重放的输出,以及自定义shrink从 6 缩到 5 的例子,是在这个版本上跑过的。跨版本不兼容和unstable标注读自 JSDoc,没有跨版本验证;「把 shrunkInput 写成 example 测试」是我的建议,不是库的要求。