Effect 原生属性测试:Arbitrary、it.effect.prop 与 TestSchema
example-based 测试写的是「我想到的几个输入」,属性测试写的是「对所有合法输入都成立的规律」,由框架生成几百个输入去找反例,找到之后再缩小成最小的失败案例。这种方式特别适合有明确代数性质的代码:编码再解码应当还原、合并操作应当满足交换律、状态转换不应当产生非法状态。
在 Effect v3 里做属性测试要引入 fast-check,再用 Arbitrary.make(schema) 桥接。v4 把这件事做成了原生能力:effect/Arbitrary 模块自己实现了生成、shrinking 与重放,不依赖 fast-check。这篇讲怎么用,以及我在 4.0.5 上踩到的两个行为。
先说稳定性:Arbitrary 模块、TestSchema、@effect/vitest 的 prop 在 JSDoc 里全部标注为 @stability unstable。其中重放 token 的文档明确写了跨版本不保证兼容。可以用,但别把 replay 字符串当成长期存档格式。验证环境:effect 4.0.5、@effect/vitest 4.0.5、vitest 5.0.5(@effect/vitest 要求 vitest 在 5.x)。
Arbitrary.schema:从 Schema 派生生成器
Arbitrary.schema(schema) 从 Schema 的 Type 一侧派生出一个 Arbitrary,它只是一个纯描述,不会自己跑,要交给 sampleEffect 或 checkEffect。Schema 里的检查会被遵守,比如下面 x 一定在 0 到 100 之间:
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)checkEffect 的返回值是普通数据而不是抛异常,CheckResult 有四种:Passed、Falsified、Exhausted(过滤太苛刻,生成的候选被丢弃太多)、ReplayMismatch(重放的 token 对不上了)。它自己不会让测试失败,需要你判断 _tag,或者用 formatCheckFailure 把非 Passed 的结果格式化成诊断文本。
可复现:seed 与 replay
两种复现手段:
seed:固定随机种子,同一个 seed 在同一实现下得到同一批输入。没传 seed 时,种子由 Effect 的Random服务选取;失败结果里的replay(或Exhausted里的seed)会记下实际用到的值。replay:Falsified结果自带的字符串,记录了种子、第几次尝试、大小以及完整的 shrink 路径。传给checkEffect的replay选项后,其它的runs、seed、size选项都会被忽略,直接重放那一次失败。
CI 里失败了,把日志里的 replay 贴到本地就能复现,这是比「再跑一遍看看」可靠得多的流程。
结构化 shrinking
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,正好是最小反例。
组合:map、filter、all
生成器可以组合,用来构造相关的输入,比如「a < b 的一对整数」:
import { Arbitrary, Effect, Schema } from "effect"
const Id = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 9 }))
const pair = Arbitrary.all({ a: Arbitrary.schema(Id), b: Arbitrary.schema(Id) }).pipe(
Arbitrary.filter(({ a, b }) => a < b)
)
const doubled = Arbitrary.schema(Id).pipe(Arbitrary.map((n) => n * 2))
Effect.runPromise(
Effect.gen(function* () {
console.log(yield* Arbitrary.sampleEffect(pair, { count: 3, seed: 1 }))
// [ { a: 2, b: 9 }, { a: 1, b: 2 }, { a: 3, b: 5 } ]
console.log(yield* Arbitrary.sampleEffect(doubled, { count: 3, seed: 1 })) // [ 6, 18, 16 ]
})
)filter 过度会导致 Exhausted,能用构造方式表达的约束(map、flatMap)优先于事后过滤。
@effect/vitest:it.prop 与 it.effect.prop
手写 checkEffect 再判断 _tag 很啰嗦,@effect/vitest 把它包成了测试注册 API。it.prop 用于同步属性,it.effect.prop 用于返回 Effect 的属性,参数可以是 Schema、Arbitrary,写成对象或元组都行,回调里按同样的结构拿到值。
import { describe, expect, it } from "@effect/vitest"
import { Effect, Schema } from "effect"
const Money = Schema.Struct({
amount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 1_000_000 })),
currency: Schema.Literals(["CNY", "USD"])
})
type Money = typeof Money.Type
const add = (a: Money, b: Money): Money => ({ amount: a.amount + b.amount, currency: a.currency })
describe("Money", () => {
// 同步属性:抛异常或返回 false 都算失败
it.prop("add 满足交换律", { a: Money, b: Money }, ({ a, b }) => {
const b2 = { ...b, currency: a.currency }
expect(add(a, b2)).toEqual(add(b2, a))
})
// Effect 属性:typed failure、抛异常、defect 都算失败;选项里可固定 seed 和 runs
it.effect.prop(
"编码再解码得到原值",
{ m: Money },
({ m }) =>
Effect.gen(function* () {
const encoded = yield* Schema.encodeEffect(Money)(m)
const decoded = yield* Schema.decodeUnknownEffect(Money)(encoded)
expect(decoded).toEqual(m)
}),
{ arbitrary: { runs: 50, seed: 7 } }
)
})失败时输出会带上 shrink 之后的输入和 replay。我故意写了一个有 bug 的属性(对 { lo, hi } 断言 hi - lo >= 0),vitest 的报告是这样的:
Error: Property falsified after 3 run(s) and 0 shrink(s)
Shrunk input: {"r":{
"lo": 1,
"hi": 0
}}
Failure: AssertionError: expected -1 to be greater than or equal to 0
Replay: [0,"1",2,0,[],"PropertyError"]两个行为要知道:
- 回调里用
expect抛出的断言错误也会被当成「属性被证伪」并触发 shrink,所以不需要把断言改写成返回布尔值。 - vitest 的超时会中断运行生成、属性和 shrink 的 fiber,Effect 的 finalizer 会执行;但同步的死循环无法被抢占。
TestSchema.Asserts:Schema 自己的断言
effect/testing 里的 TestSchema.Asserts 是专门测 Schema 的断言集合,用 new TestSchema.Asserts(schema) 创建:
decoding().succeed(input, expected?)和decoding().fail(input, message):断言解码结果或格式化后的错误信息。encoding().succeed(value, expected?):断言编码结果。verifyRoundTrip(options?):对生成的值做「编码再解码」,断言与原值深度相等,失败时带 shrink 后的输入和 replay。arbitrary().verifyGeneration(options?):检查生成器产出的值都满足 Schema。decoding和encoding另有succeedEffect、failEffect,往返有verifyRoundTripEffect,返回惰性的 Effect,用于需要服务的 Schema。
import { describe, it } from "@effect/vitest"
import { Schema } from "effect"
import { TestSchema } from "effect/testing"
class Payment extends Schema.Class<Payment>("Payment")({
id: Schema.Int,
amount: Schema.NumberFromString
}) {}
describe("Payment schema", () => {
const asserts = new TestSchema.Asserts(Payment)
it("编码", async () => {
await new TestSchema.Asserts(Schema.NumberFromString).encoding().succeed(42, "42")
})
it("解码失败的信息", async () => {
await asserts
.decoding({ parseOptions: { errors: "all" } })
.fail({ id: 1.5, amount: 5 }, [
"Expected an integer",
" at [\"id\"]",
"Expected string",
" at [\"amount\"]"
].join("\n"))
})
it("往返", async () => {
await asserts.verifyRoundTrip({ runs: 50, seed: 3 })
asserts.arbitrary().verifyGeneration({ seed: 1 })
})
})这一段里「解码失败的信息」断言的是完整的格式化文本,errors: "all" 让所有字段的错误都出现,而不是第一个就停。
一个坑:Struct 上的 verifyRoundTrip(4.0.5)
我在 4.0.5 上遇到的实际行为:对 Schema.Struct 调 verifyRoundTrip 会失败,即使 Schema 完全正常。例如 new TestSchema.Asserts(Schema.Struct({ a: Schema.String })).verifyRoundTrip({ seed: 1 }) 报 Shrunk input: {"a":""}、Failure: returned false。
原因是我实际排查出来的两点。Struct 的 Arbitrary 产出的对象原型是 null(Object.getPrototypeOf 得到 null),而解码结果是普通对象;verifyRoundTrip 用 Node 的 isDeepStrictEqual 比较,它会比较原型,所以即使字段完全相同也判为不等。另外 Schema.Number 的生成器会产出 -0,NumberFromString 往返后变成 0,同样被严格相等拒绝。Schema.Class 的实例不受影响,上面的 Payment 就能通过,纯 string 这类原始 Schema 也能通过。
这是 4.0.5 的现状,我没有去查它是不是已被确认为缺陷,所以不替它下结论。可行的绕法是自己用 checkEffect 加 Schema.toEquivalence,比较规则由 Schema 决定,不依赖原型:
import { Arbitrary, Effect, Schema } from "effect"
const Wire = Schema.Struct({
id: Schema.Int,
at: Schema.DateTimeUtcFromString,
n: Schema.NumberFromString
})
const roundTrip = Effect.gen(function* () {
const eq = Schema.toEquivalence(Wire)
const result = yield* Arbitrary.checkEffect(
Arbitrary.schema(Wire),
(w) =>
Schema.encodeEffect(Wire)(w).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(Wire)),
Effect.map((back) => eq(back, w))
),
{ runs: 200, seed: 1 }
)
console.log(result._tag) // Passed
})
Effect.runPromise(roundTrip)选型
- 有明确代数性质(往返、幂等、交换、不变量)的纯逻辑:
it.prop,这是投入产出比最高的地方。 - 需要 Effect 环境、服务或异步的属性:
it.effect.prop,配合运行时边界与测试那篇里的TestClock与 Layer。 - Schema 本身的编解码行为:
TestSchema.Asserts,具体输入输出用decoding、encoding,往返用verifyRoundTrip(Struct 上先确认上面的坑)。 - 需要在非 vitest 环境,或要对结果做自定义处理:直接用
checkEffect。
属性测试不替代 example 测试。我的做法是 example 测试固定已知的边界和回归,属性测试覆盖「我没想到的输入」,两者互补,不互相替代。
小结
v4 的属性测试不再依赖 fast-check:Arbitrary.schema 从 Schema 派生生成器并遵守所有检查,checkEffect 把结果作为数据返回,带可复现的 seed、可重放的 replay 以及结构化 shrinking;@effect/vitest 的 it.prop 与 it.effect.prop 把它接进测试注册;TestSchema.Asserts 覆盖编码、解码和往返断言。
要记住三件事:这几个模块都是 unstable,replay 不跨版本;shrinking 受 maxShrinks 限制,不保证全局最小;4.0.5 上 Struct 的 verifyRoundTrip 会因原型比较失败,用 checkEffect 加 toEquivalence 代替。