Testing Substrate
Property-based testing lets you verify laws your code must obey — round-trip serialization, idempotence, error handling exhaustiveness — by generating arbitrary inputs and checking they all satisfy your assertion. Corpus ships a property-testing substrate built on fast-check with domain-specific helpers for your data types.
Why Property-Based Testing?
Traditional example-based tests check a few handpicked cases. Property tests generate hundreds of random cases and shrink failing inputs to a minimal reproduction. This catches edge cases you wouldn’t think to write by hand.
// Example-based: you pick specific casestest("round-trip encodes and decodes", () => { const text = "hello"; const encoded = text_codec.encode(text); const decoded = text_codec.decode(encoded); expect(decoded).toBe(text);});
// Property-based: fast-check generates cases for youimport { testing, text_codec } from "@f0rbit/corpus";
test("round-trip works for all strings", async () => { await testing.law.round_trip( testing.fc.string(), text_codec.encode, text_codec.decode );});The Substrate: Arbitraries, Laws, and Registry
Corpus provides four layers:
-
Arbitraries — Generators that produce test data matching your types.
- Use
testing.arb(schema)to derive a generator from any Zod schema. - Or
testing.compose(draw => ...)for dependent generation (later values depend on earlier ones).
- Use
-
Laws — Pre-built property checkers for common patterns.
law.round_trip— serialization round-trips correctly.law.idempotent— applying a function twice gives the same result as once.law.functor— a function respects the functor structure.law.error_path_exhaustive— every error variant the function can return is tested.law.provider_equivalence— multiple implementations behave the same way.
-
Registry — Canonical arbitraries for your domain types, auto-loaded from dependencies.
- Register a type once with
testing.arbitrary(brand, gen). - Every test using that brand gets the same generator.
- Dependencies vend arbitraries via
package.jsonand are auto-discovered.
- Register a type once with
-
Vending — How packages share test generators with each other.
- Each package declares
"corpus": { "testing": "./path/to/register.js" }inpackage.json. - That file exports a
register()function that callstesting.arbitrary(...)andtesting.failure(...). - The first call to
testing.lookup(...)auto-loads all registered generators.
- Each package declares
Import Paths
The testing substrate is exposed under two paths — pick whichever fits your use:
// Direct entry point (preferred in hot-path tests — skips main barrel)import * as testing from "@f0rbit/corpus/testing";
// Or via the main barrel (only one import for everything)import { testing } from "@f0rbit/corpus";
// Both resolve to the same APIconst arb = testing.arb(MySchema);const result = await testing.law.round_trip(arb, encode, decode);The second path (import { testing }) is the main barrel and pulls in the full @f0rbit/corpus API, which you might not need in a test file. The first path (import * as testing) is a direct entry point and skips the main barrel’s transitive imports.
What’s Inside
testing.arb(schema)— Derive a fast-checkArbitraryfrom a Zod schema.testing.compose(draw => ...)— Build dependent generators with inline draws.testing.arbitrary(brand, gen)/testing.lookup(brand)— Register and look up canonicals.testing.failure(brand, kind, gen)/testing.lookup_failure(brand, kind)— Register error variants.testing.law.*— Five pre-built laws:round_trip,idempotent,functor,error_path_exhaustive,provider_equivalence.testing.cover(...)— Hedgehog-style coverage assertions (label parts of your test space and assert they got exercised).testing.load_from(dir)/testing.__reset_registry_for_tests()— Explicit control for test setup.testing.fc— fast-check re-exported (same asimport fc from "fast-check").
Next Steps
- Arbitraries — How to generate test data from schemas and register canonicals.
- Laws — Five pre-built property checkers with examples.
- Vending — How to share arbitraries across packages via the registry.