属性测试失败后,怎么复现并看到最小反例?

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 测试」是我的建议,不是库的要求。