Schema & Property Testing
Two testing tools ship under effect/testing:
TestSchema— assert how aSchemadecodes, encodes, constructs (make), and round-trips a value, with one assertion per case.FastCheck— a re-export of the fast-check property-based testing library, plusSchema.toArbitraryto derive generators straight from your schemas.
Both are framework-agnostic. TestSchema assertions are plain async functions
(Promise<void>), so you call them from a regular it/test body with await
— they are not it.effect bodies. For property testing wired into
@effect/vitest (running an Effect per generated case), see
it.effect.prop.
Part 1 — Testing Schemas with TestSchema
Section titled “Part 1 — Testing Schemas with TestSchema”The entry point is new TestSchema.Asserts(schema). From it you pick a
direction — decoding(), encoding(), make() — and call succeed / fail.
Every assertion is async, so await it.
import { Schema } from "effect"import { TestSchema } from "effect/testing"import { describe, it } from "vitest"
describe("NumberFromString", () => { it("decodes and encodes", async () => { const asserts = new TestSchema.Asserts(Schema.NumberFromString)
// decoding: string -> number const decoding = asserts.decoding() await decoding.succeed("1", 1) // "1" decodes to 1 await decoding.fail(null, "Expected string, got null")
// encoding: number -> string const encoding = asserts.encoding() await encoding.succeed(1, "1") // 1 encodes to "1" })})A few rules that apply to every assertion:
succeed(input)with one argument asserts an identity — the output equals the input unchanged. Use it for schemas that do not transform (e.g.Schema.String).succeed(input, expected)with two arguments asserts a specific transformed output. Use it for codecs likeNumberFromString.fail(input, message)compares against the stringifiedIssue, not theIssueobject. Pass the exact text the issue produces (e.g."Expected a value greater than 0, got -1"). Comparisons useassert.deepStrictEqual, so structural — not reference — equality is required.
new TestSchema.Asserts(schema)
Section titled “new TestSchema.Asserts(schema)”Wraps a schema and exposes the per-direction helpers. This is the only constructor you call directly.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
const asserts = new TestSchema.Asserts(Schema.Struct({ name: Schema.String }))// asserts.decoding() / asserts.encoding() / asserts.make() / asserts.arbitrary()// asserts.verifyLosslessTransformation()asserts.decoding(options?)
Section titled “asserts.decoding(options?)”Returns a Decoding instance for testing the decode direction (unknown input →
the schema’s Type). Pass parseOptions to control error reporting, e.g.
{ errors: "all" } to collect every issue instead of stopping at the first.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
const schema = Schema.FiniteFromString.check(Schema.isGreaterThan(0))const decoding = new TestSchema.Asserts(schema).decoding()
await decoding.succeed("1", 1) // "1" -> 1await decoding.fail("-1", "Expected a value greater than 0, got -1")await decoding.fail("a", "Expected a finite number, got NaN")
// collect all issues for a struct instead of failing fastconst all = new TestSchema.Asserts( Schema.Struct({ a: Schema.String, b: Schema.Number })).decoding({ parseOptions: { errors: "all" } })decoding.succeed(input) / decoding.succeed(input, expected)
Section titled “decoding.succeed(input) / decoding.succeed(input, expected)”One argument asserts identity; two arguments assert a transformed result.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
// identity: String does not transformawait new TestSchema.Asserts(Schema.String).decoding().succeed("hello")// => ok, output === "hello"
// transformedawait new TestSchema.Asserts(Schema.NumberFromString).decoding().succeed("42", 42)// => ok, "42" decoded to 42decoding.fail(input, message)
Section titled “decoding.fail(input, message)”Asserts decoding fails with the given stringified issue.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
await new TestSchema.Asserts(Schema.String).decoding().fail(42, "Expected string, got 42")// => ok, decoding 42 produced that issuedecoding.provide(key, implementation)
Section titled “decoding.provide(key, implementation)”Returns a new Decoding with a service injected into the decoder’s context. Use
it when a schema’s decoder depends on a service (for
example a schema built with Schema.decode whose getter reads a service).
import { Context, Effect, Option, Schema, SchemaGetter, SchemaIssue } from "effect"import { TestSchema } from "effect/testing"
class Service extends Context.Service<Service, { fallback: Effect.Effect<string> }>()("Service") {}
const schema = Schema.String.pipe( Schema.decode({ decode: SchemaGetter.checkEffect((s) => Effect.gen(function*() { yield* Service if (s.length === 0) { return new SchemaIssue.InvalidValue(Option.some(s), { message: "input should not be empty string" }) } }) ), encode: SchemaGetter.passthrough() }))
const decoding = new TestSchema.Asserts(schema) .decoding() .provide(Service, { fallback: Effect.succeed("b") })
await decoding.succeed("a")await decoding.fail("", "input should not be empty string")asserts.encoding(options?)
Section titled “asserts.encoding(options?)”The mirror of decoding(): returns an Encoding instance that exercises the
encode direction (the schema’s Type → its Encoded form). Same succeed /
fail / provide API and the same parseOptions.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
const schema = Schema.FiniteFromString.check(Schema.isGreaterThan(0))const encoding = new TestSchema.Asserts(schema).encoding()
await encoding.succeed(1, "1") // 1 -> "1"await encoding.fail(-1, "Expected a value greater than 0, got -1")Pairing both directions verifies a codec round-trips for a concrete value:
import { Schema } from "effect"import { TestSchema } from "effect/testing"
const asserts = new TestSchema.Asserts(Schema.NumberFromString)await asserts.decoding().succeed("3.14", 3.14) // string -> numberawait asserts.encoding().succeed(3.14, "3.14") // number -> stringencoding.succeed / encoding.fail / encoding.provide
Section titled “encoding.succeed / encoding.fail / encoding.provide”Behave exactly like their Decoding counterparts, but in the encode direction.
succeed(input) asserts identity, succeed(input, expected) asserts the encoded
output, fail(input, message) asserts an encoding failure, and provide injects
a service the encoder requires.
import { Context, Effect, Schema, SchemaGetter } from "effect"import { TestSchema } from "effect/testing"
class Service extends Context.Service<Service, { fallback: Effect.Effect<string> }>()("Service") {}
const schema = Schema.String.pipe( Schema.decode({ decode: SchemaGetter.passthrough(), encode: SchemaGetter.checkEffect(() => Effect.as(Service, undefined)) }))
const encoding = new TestSchema.Asserts(schema) .encoding() .provide(Service, { fallback: Effect.succeed("b") })
await encoding.succeed("a")asserts.make(options?)
Section titled “asserts.make(options?)”Returns { succeed, fail } for the schema’s make (construction) operation —
how a schema accepts, transforms, or rejects in-memory construction input.
succeed(input) asserts make returns the input unchanged, succeed(input, expected) asserts the constructed value, and fail(input, message) asserts
construction fails.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
const make = new TestSchema.Asserts(Schema.String).make()await make.succeed("hello") // => constructs "hello"
// a constrained schema rejects out-of-range construction inputconst positive = new TestSchema.Asserts( Schema.Number.check(Schema.isGreaterThan(0))).make()await positive.fail(-1, "Expected a value greater than 0, got -1")asserts.verifyLosslessTransformation(options?)
Section titled “asserts.verifyLosslessTransformation(options?)”A property-based assertion: FastCheck generates arbitrary values of the schema’s
Type, encodes each, decodes it back, and asserts the result equals the
original. This proves a codec is lossless across many inputs in one line. Pass
options.params to tune FastCheck (for example numRuns).
import { Schema } from "effect"import { TestSchema } from "effect/testing"import { it } from "vitest"
it("round-trips", async () => { const asserts = new TestSchema.Asserts( Schema.FiniteFromString.check(Schema.isGreaterThan(0)) ) // encode -> decode === original, for every generated value await asserts.verifyLosslessTransformation()
// with more runs await asserts.verifyLosslessTransformation({ params: { numRuns: 1000 } })})asserts.arbitrary().verifyGeneration(options?)
Section titled “asserts.arbitrary().verifyGeneration(options?)”Generates arbitrary values for the schema and asserts each one satisfies the
schema’s is predicate — i.e. the derived generator only produces valid values.
Defaults to 20 runs; override via options.params.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
new TestSchema.Asserts(Schema.String).arbitrary().verifyGeneration()// => generates 20 strings, asserts Schema.is(Schema.String) for each
new TestSchema.Asserts( Schema.Struct({ name: Schema.String, age: Schema.Number })) .arbitrary() .verifyGeneration({ params: { numRuns: 100 } })TestSchema.Asserts.ast.fields.equals(a, b)
Section titled “TestSchema.Asserts.ast.fields.equals(a, b)”A static helper. Asserts that two sets of struct fields produce the same AST
(via assert.deepStrictEqual over SchemaAST.getAST of each field). Useful for
testing schema-building helpers that should yield equivalent field shapes.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
TestSchema.Asserts.ast.fields.equals( { name: Schema.String }, { name: Schema.String })// => no error (structurally equal)TestSchema.Asserts.ast.elements.equals(a, b)
Section titled “TestSchema.Asserts.ast.elements.equals(a, b)”The tuple-element counterpart: asserts two tuple element lists produce the same element ASTs.
import { Schema } from "effect"import { TestSchema } from "effect/testing"
TestSchema.Asserts.ast.elements.equals( [Schema.String, Schema.Number], [Schema.String, Schema.Number])// => no errorPart 2 — Property-based testing with FastCheck
Section titled “Part 2 — Property-based testing with FastCheck”import { FastCheck } from "effect/testing" re-exports the whole
fast-check library. Property-based testing flips the
usual model: instead of hand-picking inputs, you state a property that should
hold for all inputs, FastCheck generates many random values, and on failure
it shrinks the counterexample down to the smallest input that breaks the
property.
import { FastCheck } from "effect/testing"import { it } from "vitest"
it("reverse of reverse is identity", () => { const property = FastCheck.property( FastCheck.array(FastCheck.integer()), (xs) => { const twice = xs.slice().reverse().reverse() return JSON.stringify(twice) === JSON.stringify(xs) } ) // generate inputs, run the predicate, throw on a counterexample FastCheck.assert(property)})FastCheck.property takes one or more arbitraries plus a predicate that receives
one generated value per arbitrary and returns a boolean (or asserts internally
and returns void). FastCheck.assert runs it.
FastCheck.asyncProperty
Section titled “FastCheck.asyncProperty”Like property, but the predicate returns a Promise. Run it with the same
FastCheck.assert (which awaits async properties).
import { FastCheck } from "effect/testing"import { it } from "vitest"
it("async predicate", async () => { const property = FastCheck.asyncProperty( FastCheck.string(), async (s) => { const echoed = await Promise.resolve(s) return echoed === s } ) await FastCheck.assert(property)})Commonly used arbitraries and utilities
Section titled “Commonly used arbitraries and utilities”Each entry below is a fast-check export reachable as FastCheck.*. These are
the building blocks you compose into properties.
FastCheck.integer / FastCheck.string / FastCheck.boolean
Section titled “FastCheck.integer / FastCheck.string / FastCheck.boolean”Primitive generators. integer and string accept constraints such as
{ min, max } and { minLength, maxLength }.
import { FastCheck } from "effect/testing"
FastCheck.sample(FastCheck.integer({ min: 0, max: 10 }), 3)// => e.g. [7, 0, 4]FastCheck.sample(FastCheck.string({ minLength: 1 }), 2)// => e.g. ["a9", "Q"]FastCheck.array
Section titled “FastCheck.array”Generates arrays of a given element arbitrary; accepts { minLength, maxLength }.
import { FastCheck } from "effect/testing"
FastCheck.sample(FastCheck.array(FastCheck.integer(), { maxLength: 3 }), 2)// => e.g. [[1], [4, -2, 0]]FastCheck.record
Section titled “FastCheck.record”Generates an object from a record of arbitraries — one per key.
import { FastCheck } from "effect/testing"
const person = FastCheck.record({ name: FastCheck.string({ minLength: 1 }), age: FastCheck.integer({ min: 0, max: 120 }), email: FastCheck.emailAddress()})FastCheck.sample(person, 1)// => e.g. [{ name: "Ada", age: 36, email: "a@b.cd" }]FastCheck.constant
Section titled “FastCheck.constant”Always generates the same value — handy as a branch in oneof.
import { FastCheck } from "effect/testing"
FastCheck.sample(FastCheck.constant("fixed"), 2)// => ["fixed", "fixed"]FastCheck.oneof
Section titled “FastCheck.oneof”Picks from several arbitraries, producing a union of their outputs.
import { FastCheck } from "effect/testing"
const status = FastCheck.oneof( FastCheck.constant("active"), FastCheck.constant("inactive"))FastCheck.sample(status, 3)// => e.g. ["active", "inactive", "active"]FastCheck.emailAddress
Section titled “FastCheck.emailAddress”Generates syntactically valid email strings.
import { FastCheck } from "effect/testing"
FastCheck.sample(FastCheck.emailAddress(), 1)// => e.g. ["foo.bar@example.com"]FastCheck.sample
Section titled “FastCheck.sample”Draws a fixed number of values from an arbitrary without running a property — useful for exploring what a generator produces.
import { FastCheck } from "effect/testing"
FastCheck.sample(FastCheck.boolean(), 4)// => e.g. [true, false, false, true]FastCheck.assert
Section titled “FastCheck.assert”Runs a property (sync or async) and throws on the first counterexample, reporting
the shrunk input. Accepts a second Parameters argument to tune the run.
import { FastCheck } from "effect/testing"
FastCheck.assert( FastCheck.property(FastCheck.integer(), (n) => Number.isInteger(n)), { numRuns: 500 } // run 500 cases instead of the default)FastCheck.Parameters and numRuns
Section titled “FastCheck.Parameters and numRuns”FastCheck.Parameters<Ts> is the options type accepted by assert. The most
common field is numRuns (how many cases to generate); others include seed and
endOnFailure for reproducible runs.
import { FastCheck } from "effect/testing"
const params: FastCheck.Parameters<[number]> = { numRuns: 100, seed: 42 }FastCheck.assert( FastCheck.property(FastCheck.integer(), (n) => n - n === 0), params)FastCheck.Arbitrary
Section titled “FastCheck.Arbitrary”The type of a generator. Schema.toArbitrary returns a
FastCheck.Arbitrary<S["Type"]>, and every FastCheck.* builder above produces
one.
import { FastCheck } from "effect/testing"
const ageArb: FastCheck.Arbitrary<number> = FastCheck.integer({ min: 0, max: 120 })Deriving arbitraries from a Schema
Section titled “Deriving arbitraries from a Schema”You rarely have to hand-build arbitraries for your domain types: any
Schema can produce one with Schema.toArbitrary, returning a
FastCheck.Arbitrary of the schema’s Type. This keeps generators in sync with
validation — see Schema derivations.
import { Schema } from "effect"import { FastCheck } from "effect/testing"import { it } from "vitest"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))})
const personArb = Schema.toArbitrary(Person) // FastCheck.Arbitrary<{ name: string; age: number }>
it("every generated person is non-negative age", () => { FastCheck.assert( FastCheck.property(personArb, (p) => p.age >= 0) )})