Skip to content

Schema & Property Testing

Two testing tools ship under effect/testing:

  • TestSchema — assert how a Schema decodes, 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, plus Schema.toArbitrary to 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 like NumberFromString.
  • fail(input, message) compares against the stringified Issue, not the Issue object. Pass the exact text the issue produces (e.g. "Expected a value greater than 0, got -1"). Comparisons use assert.deepStrictEqual, so structural — not reference — equality is required.

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()

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" -> 1
await 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 fast
const 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 transform
await new TestSchema.Asserts(Schema.String).decoding().succeed("hello")
// => ok, output === "hello"
// transformed
await new TestSchema.Asserts(Schema.NumberFromString).decoding().succeed("42", 42)
// => ok, "42" decoded to 42

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 issue

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")

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 -> number
await asserts.encoding().succeed(3.14, "3.14") // number -> string

encoding.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")

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 input
const 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 error

Part 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.

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)
})

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"]

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]]

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" }]

Always generates the same value — handy as a branch in oneof.

import { FastCheck } from "effect/testing"
FastCheck.sample(FastCheck.constant("fixed"), 2)
// => ["fixed", "fixed"]

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"]

Generates syntactically valid email strings.

import { FastCheck } from "effect/testing"
FastCheck.sample(FastCheck.emailAddress(), 1)
// => e.g. ["foo.bar@example.com"]

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]

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<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
)

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 })

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)
)
})