Documentation
¶
Overview ¶
Package ferrytest is ferry's driver contract in executable form: the conformance suites, the round-trip property harness, the memory plane and the recording sink.
The words this package uses ¶
A plane is wherever a driver keeps configuration - a YAML file, a set of environment variables, a key-value store - seen through ferry's own boundary. Plane is a description of one that a driver author fills in, and every suite here takes that description rather than a driver type.
An address is where one field lands on a plane, and the address set is every address a struct names. A driver is handed the whole set before any I/O, which is where a schema the plane cannot hold gets refused.
A kind is one of the six shapes a value has as it crosses ferry's boundary: Absent, Null, Bool, Number, String and Bytes. A plane declares which of them it can carry, and that declaration is the thing a suite holds it to.
Conformance is the twenty-three-case suite Driver runs over one plane. Passing it is what "this driver implements ferry's contract" means.
Who this is for ¶
A driver author writes one test, and it is the whole file:
func TestConformance(t *testing.T) {
ferrytest.Driver(t, myPlane())
}
A codec author, who has registered a Go type with ferry, writes four calls: RoundTrip to drive their own values through the engine against MemPlane, Codec to check the registration itself, Injective over the values they will use as map keys, and Complete to catch a registered type they wrote no proof for.
ferry's own tests run CoreTypes through the same RoundTrip, which is what makes these the suites everybody gets rather than a second opinion.
A driver author who declares one of their plane's own spellings runs Spelling over it, which holds the pair to the rules that keep what a plane writes readable by the plane that wrote it.
And an ordinary user, who is not testing ferry at all: Static fills a config struct from a literal instead of from a file, and Record answers what a struct actually maps to.
Anybody asserting that a call failed the way it should uses CheckErrors, or DiffErrors for the same answer as data. ferry's message text is not API, so the assertion is an exact set of Want over the address and the sentinel, and no substring appears in it anywhere.
Two stability promises ¶
The apparatus - Plane, Instance, MemPlane, Static, Record, Case, Type, Proof, Want, DiffErrors, CheckErrors and the relations - is ordinary exported Go API under semver. It ends up embedded in tests that are not about ferry, and it does not move outside a major version.
The suites - Driver, RoundTrip, Codec, Complete, Injective, Spelling - may gain cases in a minor release. So a minor upgrade of ferry can make a driver that passed yesterday fail today, and that is intended: a new case does not break a driver, it reports that the driver was already broken. Nothing in the Go toolchain can warn you first, because adding a case changes no signature and no exported name.
The design records behind these decisions are in docs/adr/.
Index ¶
- func BitEq[T ~float32 | ~float64](a, b T) bool
- func CheckErrors(t T, got error, want ...Want)
- func Codec(t T, reg *ferry.Registry, opts ...ferry.Option)
- func Complete(reg *ferry.Registry, proofs ...Proof) []string
- func DiffErrors(got error, want ...Want) []string
- func Driver(t T, p Plane, opts ...ferry.Option)
- func Eq[T comparable](a, b T) bool
- func Injective[T comparable](reg *ferry.Registry, values ...T) []string
- func MapEq[K comparable, V any](eq func(a, b V) bool) func(a, b map[K]V) bool
- func PtrEq[T any](eq func(a, b T) bool) func(a, b *T) bool
- func Record[T any](ctx context.Context, v T, opts ...ferry.Option) (map[ferry.Path]ferry.Value, error)
- func RoundTrip(t T, p Plane, proofs []Proof, opts ...ferry.Option)
- func SliceEq[T any](eq func(a, b T) bool) func(a, b []T) bool
- func Spelling[P, C any](t T, s ferry.Spelling[P, C], eq func(a, b P) bool, payloads []P, refused []C)
- func Static(values map[ferry.Path]ferry.Value) ferry.Source
- type Artefact
- type Case
- type Instance
- type Plane
- type Proof
- type T
- type Want
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func BitEq ¶
BitEq relates two floats by their bit patterns rather than by ==, which is what makes NaN assertable at all: NaN == NaN is false, so a proof carrying NaN cannot use Eq and a harness without this relation cannot carry the value that historically breaks float codecs.
It also separates +0 from -0, which == conflates and which a plane does not: a text boundary spells them "0" and "-0", so a codec that loses the sign of zero is a codec this relation reports and == does not.
A float32 widens to float64 for the comparison. That conversion is exact for every float32 value, the zeros and the infinities included, so it changes no answer this relation can give.
func CheckErrors ¶
CheckErrors fails t with one line per difference between the failures a call produced and the failures it was expected to produce.
ferrytest.CheckErrors(t, err,
ferrytest.Want{Address: ferry.At("db", "host"), Class: ferry.ErrMissing},
ferrytest.Want{Address: ferry.At("db", "port"), Class: ferry.ErrValue},
)
It is DiffErrors reported to a test and the semantics are that function's: an exact set over address and class, with no assertion on message text at any level. A call that reported exactly the wanted failures reports nothing here.
t is T rather than *testing.T, which *testing.T satisfies for free, so the check runs from a probe as well as from a test.
Example ¶
ExampleCheckErrors is the same check reported to a test, which is what a test writes. Pass the *testing.T; the stand-in below is only so that the example can print what the check said.
package main
import (
"context"
"fmt"
"github.com/onhotpath/ferry"
"github.com/onhotpath/ferry/ferrytest"
)
// ExampleRequired is the struct the two error examples load, and the plane they
// load from holds nothing at the address it marks required.
type ExampleRequired struct {
Port int `ferry:"port,required"`
Timeout string `ferry:"timeout"`
}
// exampleLoad is the failing load both error examples assert over.
func exampleLoad() error {
_, err := ferry.Load[ExampleRequired](context.Background(), ferrytest.Static(map[ferry.Path]ferry.Value{
ferry.At("timeout"): ferry.String("30s"),
}))
return err
}
func main() {
var t exampleT
ferrytest.CheckErrors(&t, exampleLoad(),
ferrytest.Want{Address: ferry.At("port"), Class: ferry.ErrMissing},
)
fmt.Println(len(t), "failures reported")
}
// exampleT stands in for the *testing.T a real test passes, and it is two
// methods because that is all [ferrytest.T] is.
type exampleT []string
func (t *exampleT) Errorf(format string, args ...any) { *t = append(*t, fmt.Sprintf(format, args...)) }
func (*exampleT) Helper() {}
Output: 0 failures reported
func Codec ¶
Codec is the codec conformance suite: seven cases over the registration machinery, and every one of them that the zero value can reach run again over each codec the registry actually holds.
func TestCodec(t *testing.T) {
reg := ferry.MustRegistry(ferry.StringText[netip.Addr]().AsMapKey())
ferrytest.RoundTrip(t, ferrytest.MemPlane(), proofs, ferry.WithRegistry(reg))
ferrytest.Codec(t, reg)
}
It takes no context.Context, for Driver's reason.
What it promises, exactly ¶
Two things, and the second is bounded in a way worth reading before relying on it.
That ferry's registration machinery works in this build. A defect in the piece of reflection every registration goes through is a defect in every codec anybody ever registers, and it is one no proof a registrant could write would catch, because their codec is correct.
And that every codec in reg survives its own zero value. For each type the registry holds, the suite builds an annotated struct around that type and walks it: the zero value encodes, what ferry wrote loads back, encoding what came back writes the same thing again, and the same text read as a plain string loads identically. The zero value is the bound because it is the only value this suite has without being handed one.
What it does not promise ¶
It does not check your codec away from its zero value, and most of what makes a codec wrong lives there. A lossy codec and a constant codec pass every case here, because both are correct at the zero value. So do two map keys that fold to one address, which need two values to see at all, and so does a null policy that disagrees with itself away from the zero value: where the disagreement is at the zero value the per-registrant round trip catches it, and where it is anywhere else nothing here has a value to find it with.
What closes that gap is RoundTrip, which drives your own values through the real engine, Injective over the values you will use as map keys, and Complete, which reports a registered type you wrote no Proof for. Run all four. A green Codec on its own says the machinery is sound and your codec is sound at one value, and that is the whole of it.
func Complete ¶
Complete reports every type ferry carries that the supplied proofs do not discharge.
for _, s := range ferrytest.Complete(nil, ferrytest.CoreTypes()...) {
t.Errorf("core type set: %s", s)
}
for _, s := range ferrytest.Complete(reg, append(ferrytest.CoreTypes(), mine...)...) {
t.Errorf("registry: %s", s)
}
It asks over three tables: ferry's own supported types, one representative type per kind ferry admits as a leaf, and reg. A nil registry means the first two alone.
It matters because ferry's promise about what a plane holds is exactly as wide as the proof table. A type ferry carries with no proof against it is outside that promise by accident rather than by decision, and this is what makes that visible.
It returns data rather than failing anything, so a caller decides whether an uncovered type is an error or a to-do. The result is sorted, so the report is the same string over repeated runs.
The join is by reflect.Type and never by Proof.Name, which is a label for a report: two proofs may share one and mean different types.
func DiffErrors ¶
DiffErrors reports how the failures a call produced differ from the failures it was expected to produce, as an exact set over address and class.
for _, s := range ferrytest.DiffErrors(err,
ferrytest.Want{Address: ferry.At("db", "host"), Class: ferry.ErrMissing},
ferrytest.Want{Address: ferry.At("db", "port"), Class: ferry.ErrValue},
) {
t.Errorf("load: %s", s)
}
An empty result means the call reported exactly those failures, no more and no fewer. Anything else is one line per expectation nothing matched and one line per failure nothing expected, each naming the address and the class, so a reader learns which failure went missing rather than that a count moved.
Exact rather than "contains", because ferry's diagnostics suppress a failure that is a consequence of another, and a suppression rule fails by reporting once too often. An assertion that only checks the failures it named passes straight through the extra one.
A Want and a failure pair when the addresses are equal and errors.Is answers for the class, and each pairs with at most one of the other, so two failures at one address need two Wants. Message text is asserted nowhere: a line quotes it only to say what arrived.
It returns data rather than failing anything, so a caller who is asserting that a driver fails can read the answer instead of losing their run. CheckErrors is the same check reported to a test. Lines are ordered by address, so the report is the same string over repeated runs.
Example ¶
ExampleDiffErrors asserts over the exact set of failures a load reported, which is what ferry offers in place of matching on message text.
The class here is deliberately the wrong one, so that the report shows both halves: the failure that arrived and the expectation that did not match it.
package main
import (
"context"
"fmt"
"github.com/onhotpath/ferry"
"github.com/onhotpath/ferry/ferrytest"
)
// ExampleRequired is the struct the two error examples load, and the plane they
// load from holds nothing at the address it marks required.
type ExampleRequired struct {
Port int `ferry:"port,required"`
Timeout string `ferry:"timeout"`
}
// exampleLoad is the failing load both error examples assert over.
func exampleLoad() error {
_, err := ferry.Load[ExampleRequired](context.Background(), ferrytest.Static(map[ferry.Path]ferry.Value{
ferry.At("timeout"): ferry.String("30s"),
}))
return err
}
func main() {
for _, s := range ferrytest.DiffErrors(exampleLoad(),
ferrytest.Want{Address: ferry.At("port"), Class: ferry.ErrValue},
) {
fmt.Println(s)
}
}
Output: got /port: missing, and nothing wanted it: ferry: /port: required, and nothing is set here want /port: invalid value, and nothing reported it
func Driver ¶
Driver is the driver conformance suite: twenty-three cases over one plane, and the whole of what a driver author writes.
func TestConformance(t *testing.T) {
ferrytest.Driver(t, ferrytest.Plane{
Name: "yaml",
Kinds: []ferry.VKind{ferry.KindAbsent, ferry.KindNull, ferry.KindBool,
ferry.KindNumber, ferry.KindString, ferry.KindBytes},
Except: notUTF8, // it carries String and not every value of it
Open: func() ferrytest.Instance { ... },
Golden: []ferrytest.Artefact{ferrytest.Golden(cfg, "b: !!binary aGk=\n")},
})
}
There is one call and no menu, because a suite a driver author can partially adopt measures nothing. It runs everything RoundTrip does, so a driver calling this need not call that as well.
Fill Plane.Kinds in honestly. It is what your plane carries end to end, and the suite turns it into an obligation in both directions: a value of a kind you did not declare has to be refused loudly rather than quietly mangled.
It takes no context.Context. Every walk it runs uses context.Background, because a conformance run has no deadline to inherit and no caller to cancel it.
A new case does not break a driver ¶
This suite may gain cases in a minor release of ferry, so a driver that passed yesterday can fail today and nothing in the Go toolchain will have warned you first: adding a case changes no signature and no exported name. That is intended. A new case does not break a driver, it reports that the driver was already broken, against a rule that was published before the case existed.
func Eq ¶
func Eq[T comparable](a, b T) bool
Eq relates two values by ==, and is the relation for every type whose identity Go already has right.
It is not the relation for time.Time, which is comparable and whose == is wrong: two times denoting the same instant differ if one carries a monotonic reading or a different *time.Location. That type's relation is time.Time.Equal, which is a method expression of exactly the required signature.
func Injective ¶
func Injective[T comparable](reg *ferry.Registry, values ...T) []string
Injective reports every pair of the supplied values that ferry writes to one map key, which is the obligation ferry.KeyCodec.AsMapKey declares and nothing ferry can check for you.
Two values that become one key are one entry in the loaded map, so one of them is lost. Nobody but you knows which values your program will hold, so pass the ones that are close together: the same address spelled two ways, the same identifier in two cases, a value carrying a zone or a scope.
for _, s := range ferrytest.Injective(reg,
netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("::ffff:192.0.2.1"),
netip.MustParseAddr("fe80::1%eth0"),
) {
t.Errorf("as a key: %s", s)
}
It returns data rather than failing anything, and it takes no context.Context, for Driver's reason. The result is sorted, so the report is the same string over repeated runs.
The key text comes from ferry and never from the type's own String method. What addresses a plane is what your registered key codec produces, and a type whose String differs from it would answer about the wrong text, so every value here is resolved through a real dump of a real map.
T is comparable because a Go map's key identity is ==, which is what decides how many entries the map holds and therefore what "two keys" means.
func MapEq ¶
func MapEq[K comparable, V any](eq func(a, b V) bool) func(a, b map[K]V) bool
MapEq lifts a relation on values to a relation on maps, over keys compared with ==.
The keys are compared with == and never with a lifted relation, because two keys that are == are one address on the plane and there is nothing finer for this relation to say. Injective is what checks that two keys which are not == stay two addresses.
Nil and empty are one value here too, for SliceEq's reason.
func PtrEq ¶
PtrEq lifts a relation on a type to a relation on pointers to it, relating two nils and separating a nil from a pointer to a zero value.
That separation is the whole reason the relation exists rather than being spelled ==. A pointer is how a schema says "this section may be absent", so nil against a pointer to the zero value is precisely the distinction a defaulting bug destroys, and a relation comparing addresses would report every round trip as a failure instead.
func Record ¶
func Record[T any](ctx context.Context, v T, opts ...ferry.Option) (map[ferry.Path]ferry.Value, error)
Record reports every address a value maps to and the boundary ferry.Value ferry encodes there, without a plane being touched at all.
It answers "what does my struct actually map to?", which nothing else can: where a field lands is decided by ferry's tag reading and its walk together, and neither is reachable from outside. Under the hood it is a dump into a sink that keeps what it was handed and writes it nowhere, so what comes back is what a real dump would have written.
mapped, err := ferrytest.Record(ctx, Config{})
The value matters as well as its type, because a dump writes what the value holds. A zero value asks what the type maps to; any other value answers what dumping that value would write.
It takes the same ferry.Option list as ferry.Dump, and it has to: a ferry.TagKey this call could not see would answer about a schema no load will ever build.
Example ¶
ExampleRecord answers what a struct maps to, with no plane touched at all.
The addresses are sorted here only so the example has one output; Record returns a map.
package main
import (
"context"
"fmt"
"maps"
"slices"
"github.com/onhotpath/ferry"
"github.com/onhotpath/ferry/ferrytest"
)
// ExampleConfig is the annotated struct both examples below work over.
type ExampleConfig struct {
Port int `ferry:"port"`
Timeout string `ferry:"timeout"`
}
func main() {
mapped, err := ferrytest.Record(context.Background(), ExampleConfig{Port: 8080, Timeout: "30s"})
if err != nil {
fmt.Println(err)
return
}
for _, addr := range slices.SortedFunc(maps.Keys(mapped), ferry.Path.Compare) {
fmt.Printf("%s -> %#v\n", addr, mapped[addr])
}
}
Output: /port -> number("8080") /timeout -> string("30s")
func RoundTrip ¶
RoundTrip runs every proof against one plane: each case's value is dumped, what ferry encoded is compared against the case's golden, and the value is loaded back and compared under the proof's own relation.
ferrytest.RoundTrip(t, ferrytest.MemPlane(), proofs)
It reaches the plane through ferry.Dump and ferry.Load and by no other route, so what it measures is the engine a caller uses rather than a second walk written to resemble it.
This is the call a codec author makes. Driver calls it in turn, so a driver author gets it for free and need not call it separately.
What it does not do ¶
It does not consult Plane.Kinds. It runs every case it is handed, which is what a codec author proving one type against MemPlane wants. Running the values a plane can express and demanding a loud refusal for the ones it declared it cannot carry is Driver's job, and Driver narrows each proof before handing it here.
One Option it cannot honour ¶
A proof carries a bare value, so this harness supplies the annotated struct the value travels in. ferry.TagKey renames the tag key for every type in the call, that struct included, so a tag key other than the harness's own leaves it unable to compile its own wrapper. That is refused once, up front, rather than reported as an identical failure per case.
func SliceEq ¶
SliceEq lifts a relation on elements to a relation on slices.
It takes the element relation rather than requiring comparable elements, because a slice of a type whose == is wrong is still a slice: []time.Time needs SliceEq(time.Time.Equal) and there is nothing else it could use.
Nil and empty are one value here. A composite with no elements is written the same way whether it is nil or empty, so the two are one observation on every plane, and a relation separating them would report a failure ferry has deliberately chosen. Where the difference does matter - []byte is a leaf and not a composite, so []byte(nil) and []byte{} are written differently - the Case golden is what reports it.
func Spelling ¶
func Spelling[P, C any](t T, s ferry.Spelling[P, C], eq func(a, b P) bool, payloads []P, refused []C)
Spelling holds one of a plane's spellings to the rules every spelling obeys, over the payloads and the refusals you supply.
ferrytest.Spelling(t, onOff, ferrytest.Eq[bool],
[]bool{true, false},
[]string{"yes", "1", ""},
)
The first slice is payloads this spelling must carry, and the second is carriers it must refuse. Each payload is rendered, rendered again, parsed back and parsed again, which is what proves at once that what the spelling writes is something it reads, that it writes one spelling per value, and that neither half answers differently the second time. Each refusal is parsed twice, and a spelling that returns a value instead of an error for one is reported: a carrier with no reading is a failure and never a zero value. What a refusal says is not asserted here, because message text is not API - but a spelling that quotes what it refused, bounded and escaped to one line, is what the contract asks for, and that is yours to check where you author the message.
The relation is positional and there is no default, for Type's reason: a payload type knows its own identity and reflect.DeepEqual is wrong for several. Carriers are compared with reflect.DeepEqual, which is right for them, because a carrier is the plane's own bytes or text and nothing else.
What no test can prove is that the two halves are pure functions, and the contract requires it. This probes for the observable half of it - the same input twice, the same answer - and a spelling that consults something else only sometimes will pass here and fail in production. Build a spelling over words and numbers it owns, and it cannot.
func Static ¶
Static is a source of constants: the contents are fixed when it is built and nothing writes to it afterwards.
It is the plane an ordinary user reaches for, who is not testing ferry at all and wants a config struct filled from a literal rather than from a file:
cfg, err := ferry.Load[Config](ctx, ferrytest.Static(map[ferry.Path]ferry.Value{
ferry.At("port"): ferry.Number("8080"),
ferry.At("timeout"): ferry.String("30s"),
}))
How it differs from MemPlane: this is a ferry.Source over contents you supplied, and there is no Sink beside it, so ferry.Dump into it does not compile. MemPlane describes a read-write plane that starts empty and is minted fresh on every Open, which is what a conformance run needs and what a user filling in a config does not.
The map is copied, so a later write into your map cannot reach a source already handed out. It shares everything else with the memory plane, including keying by the canonical rendering of an address and never folding case.
Example ¶
ExampleStatic fills a config struct from a literal rather than from a file, which is what a test that is not about ferry at all wants.
package main
import (
"context"
"fmt"
"github.com/onhotpath/ferry"
"github.com/onhotpath/ferry/ferrytest"
)
// ExampleConfig is the annotated struct both examples below work over.
type ExampleConfig struct {
Port int `ferry:"port"`
Timeout string `ferry:"timeout"`
}
func main() {
src := ferrytest.Static(map[ferry.Path]ferry.Value{
ferry.At("port"): ferry.Number("8080"),
ferry.At("timeout"): ferry.String("30s"),
})
cfg, err := ferry.Load[ExampleConfig](context.Background(), src)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", cfg)
}
Output: {Port:8080 Timeout:30s}
Types ¶
type Artefact ¶
type Artefact struct {
// contains filtered or unexported fields
}
Artefact is one fixed value and the plane contents that saving it must produce, byte for byte. Golden is the only way to build one.
It is opaque on purpose, because the row has to capture its value's Go type while the compiler still has it: ferry.Dump takes its schema from its type parameter, so a field of type `any` would be the schema of nothing. That is what lets Plane.Golden hold rows of different types side by side.
A change to one of these rows changes what every file, key or variable that plane has ever written means. It is a major version of the module that owns the driver, and not a test fixture edit.
func Golden ¶
Golden pins one value's spelling on a plane: dumping v must leave the plane holding exactly want.
Golden: []ferrytest.Artefact{
ferrytest.Golden(struct {
B []byte `ferry:"b"`
}{[]byte("hi")}, "b: !!binary aGk=\n"),
},
v is an ordinary annotated struct, exactly as it is at a ferry.Dump call site. A bare leaf sits at the root address instead, which most planes have no name for and refuse at Bind, so a row pinning one is a row about that plane's own root rule.
want is what Instance.Contents must yield afterwards. A plane holding more than one storage unit - a key-value store is a set of pairs and not a document - has to render itself deterministically for this comparison to mean anything, and rendering two different stores alike is a row that cannot see the difference.
type Case ¶
type Case[T any] struct { // Value is the Go value that goes in. Value T // Addr is where inside Value the golden is pinned, relative to the value's // own address. The zero Path is the value's own address, which is what [At] // builds and where a leaf and an element-free composite land. Addr ferry.Path // Want is the boundary Value ferry must produce there. Want ferry.Value }
Case is one value, one address inside it, and the boundary ferry.Value ferry must produce there. At and Inside are the ways to build one.
Want is required and there is no way to omit it. It is what pins the representation, which a round trip cannot: a codec writing a duration as 30000000000 nanoseconds round-trips perfectly and is still the wrong thing to leave in somebody's config file for the next ten years.
A Case with no Want is a case with no golden, and every suite reports it rather than running it: the zero ferry.Value is Absent, which is a plane saying it does not hold an address, and no value ferry writes is ever that. A Case is an exported struct, so leaving the field out is not a call anything can refuse, and the report is what closes that.
Addr is the address the golden is pinned at, and it names one address per case because the golden is one representation: a case asserting several would report which of them failed and not which value produced it.
func At ¶
At builds a Case out of a value and the golden it must produce at the value's own address: at this value, this representation.
ferrytest.At(netip.MustParseAddr("192.0.2.1"), ferry.String("192.0.2.1"))
It is a different function from ferry.At, which builds an address out of field names. The two appear side by side in a proof and are told apart by their package.
Use Inside for a composite that carries elements, which writes nothing at its own address.
func Inside ¶
Inside builds a Case whose golden is pinned at an address inside the value rather than at the value's own: at this address, this representation.
ferrytest.Inside([]string{""}, ferry.Path{}.Elem(0), ferry.String(""))
ferrytest.Inside(map[string]string{"http": "1"}, ferry.At("http"), ferry.String("1"))
The address is relative to the value, so it starts from the zero ferry.Path and is extended with ferry.Path.At for a member and ferry.Path.Elem for a position. The zero Path is the value's own address, which is what At builds.
It is what makes a composite carrying elements provable. Such a composite writes its elements one address down and writes nothing at its own address, so a case pinned there has no representation to assert at all, and the round trip alone cannot see what a driver wrote inside a list.
type Instance ¶
type Instance struct {
// Source is the read half.
Source ferry.Source
// Sink is the write half, over the same contents as [Instance.Source].
//
// It is nil for a plane that has no honest way to write - environment
// variables are the case - and the suite then runs the read-side cases only.
Sink ferry.Sink
// InContext puts this instance's contents into a context, and it is what a
// driver whose plane is obtained freshly per load fills in. It is nil for
// every plane whose halves already hold their own contents, which is every
// plane that does not read one from a [context.Context].
//
// A driver like that ships a constructor for a source that carries no
// plane, and a second one that puts a plane into a context. Both go here,
// and the whole of it is the driver's own two calls:
//
// Open: func() ferrytest.Instance {
// v := url.Values{}
// return ferrytest.Instance{
// Source: ferryhttp.NewQuerySource(),
// InContext: func(ctx context.Context) context.Context {
// return ferryhttp.WithQuery(ctx, v)
// },
// }
// },
//
// A sink whose plane is per request fills it in exactly the same way, and a
// plane with both halves supplies both of them from this one function,
// because an instance is both halves over one set of contents.
//
// Set it and every case runs its own I/O under the context this returns, so
// the whole suite reaches the plane the way a request would. Leave it nil
// and every case runs under [context.Background], which is what it has
// always done.
//
// Two obligations. It closes over contents minted inside [Plane.Open] and
// never over contents hoisted out of it, which is the shared plane Open
// exists to make impossible. And it supplies the same contents on every
// call, because one case opens the plane more than once and each open has
// to find what the last one wrote.
//
// It is also what makes the per-request refusal checkable: the suite calls
// this to supply the plane, and deliberately does not call it to assert that
// a load with no plane in the context is refused at the open.
InContext func(ctx context.Context) context.Context
// Contents yields this instance's raw contents, exactly as the plane holds
// them, and it is what makes [Plane.Golden] checkable.
//
// For a file-backed plane the whole implementation is
// `func() ([]byte, error) { return os.ReadFile(path) }`. It is read after
// the save has finished and after any [ferry.Committer] has committed, so a
// driver that stages is never asked what it has not written yet.
//
// It is nil for a plane with no serialization format, which is [MemPlane]:
// it stores the boundary [ferry.Value] itself, so there is no representation
// for a golden row to hold. Leaving both this and [Plane.Golden] empty skips
// the golden case. Leaving only this one nil, while pinning a spelling, is
// reported rather than quietly passing.
Contents func() ([]byte, error)
}
Instance is one freshly minted plane: both halves of it over one set of contents, and the way to read those contents back as the plane spells them.
Plane.Open returns one, and returns a new one every time it is called. Both halves come back together, in one call, because two halves over different contents is the mistake a round trip cannot detect: the save would succeed, the load would report everything missing, and nothing would say why.
type Plane ¶
type Plane struct {
// Name labels the plane in a report. It is a label and never a key: two
// planes with one name are a confusing report and not a collision.
Name string
// Kinds is every kind this plane carries end to end, declared by the driver
// about itself.
//
// End to end, and not what your Get returns. A flat plane stores everything
// as text and hands every value back as a String, and it still carries Bool
// and Number, because a bool written to it comes back as the same bool and
// a number as the same number. What is declared is what survives the trip.
//
// It is an obligation in both directions, and the second one is where
// drivers get this wrong.
//
// For a kind you declare, the suite runs the proofs that need it and
// expects them to pass. For a kind you do not declare, the suite writes a
// value of it anyway and expects your driver to refuse it loudly. A value
// of an undeclared kind that is quietly stored, stored as something else,
// or stored and read back as something else, is a failure.
//
// So this is a declaration and not a wish. Declaring a kind you cannot
// carry fails a proof; omitting one you can carry stops proving it and
// demands a refusal you will not make.
//
// A flattening plane with no null - environment variables, query
// parameters, headers, an opaque key-value store - declares five kinds:
//
// Kinds: []ferry.VKind{ferry.KindAbsent, ferry.KindBool,
// ferry.KindNumber, ferry.KindString, ferry.KindBytes},
//
// Null is the one it leaves out, and it is the only one. That plane stores
// everything as text, and it still carries Bool and Number, because a bool
// written to it comes back as the same bool. Omitting those two is the
// tempting mistake and it demands a store that refuses every boolean and
// every port number, which is not a key-value store.
//
// A format that carries a kind and not every value inside it says so with
// [Plane.Except] rather than by dropping the kind.
//
// Declaring a kind and then refusing one value of it is a failure and not a
// refusal. [Plane.Except] is how a plane whose format carries a kind but not
// every value of it says so.
Kinds []ferry.VKind
// Except narrows [Plane.Kinds] to the values inside a declared kind that
// this plane's own format cannot spell. It is nil for a plane that can spell
// every value of every kind it declares.
//
// Kinds is kind-granular and a format need not be. driver/yaml is the
// example: a Go string is a byte sequence and a YAML string is a Unicode
// one, so the plane carries String and cannot carry the strings that are not
// valid UTF-8. Neither half of Kinds can say that. Dropping String would
// disclaim every ordinary string the plane carries perfectly, and declaring
// String and then refusing a value of it is a failure.
//
// Except: func(v ferry.Value) bool {
// s, err := v.AsString()
// return err == nil && !utf8.ValidString(s)
// },
//
// It applies per case rather than per proof, so the string cases this plane
// does carry still run, and a narrowed proof keeps each case's own number so
// a report names the case as [CoreTypes] spells it.
//
// It is not a way to drop an inconvenient case. An excepted value goes to
// the refusal half of the suite, exactly where a kind the plane never
// declared goes, so excepting a value buys a loud refusal the driver has to
// actually make. A driver that mangles it instead is reported the same way
// as one that mangles an undeclared kind.
//
// It is a predicate and not a list, because what is being declared is a
// property of the format - "a string that is not valid UTF-8" - rather than
// a statement about whichever values the suite happens to carry today.
Except func(v ferry.Value) bool
// Open mints a fresh, empty [Instance] of the plane, on every call.
//
// Fresh on every call is the requirement, not the convention. A temp file,
// a new map, a new fake store - built inside this closure, never hoisted out
// of it. A plane shared across cases is the defect that hides a broken
// second walk, and it is the mistake this field exists to make impossible.
//
// Open: func() ferrytest.Instance {
// path := filepath.Join(t.TempDir(), "plane.yaml")
// return ferrytest.Instance{
// Source: yaml.NewSource(path),
// Sink: yaml.NewSink(path),
// Contents: func() ([]byte, error) { return os.ReadFile(path) },
// }
// },
Open func() Instance
// Golden pins this driver's own spelling of a fixed value, byte for byte,
// and it is empty for a plane that has no serialization format.
//
// It is what catches an encoder and a decoder that are wrong in the same
// direction, which no round trip can see: a round trip tests a function
// against its own inverse, so changing both halves together is invisible to
// it. What is pinned is the driver author's own choice, because ferry
// constrains no indentation and no key order.
//
// Build the rows with [Golden]. Checking them needs [Instance.Contents], and
// a Plane that pins a spelling while yielding no way to read it is reported
// rather than quietly skipped.
Golden []Artefact
}
Plane describes one driver's plane to the suites in this package, and it is what every suite here takes instead of a driver type.
ferrytest.Driver(t, ferrytest.Plane{
Name: "yaml",
Kinds: []ferry.VKind{ferry.KindAbsent, ferry.KindNull, ferry.KindBool,
ferry.KindNumber, ferry.KindString, ferry.KindBytes},
Except: notUTF8,
Open: func() ferrytest.Instance { ... },
Golden: []ferrytest.Artefact{ferrytest.Golden(cfg, "b: !!binary aGk=\n")},
})
Nothing a suite needs about a plane is a method: what to call it in a report, which kinds it can carry, how to mint a fresh empty one, and what its own spelling of a known value looks like. So a driver this package has never heard of is described in a struct literal, and there is no interface to implement.
Instance is one minted plane, which is what Plane.Open hands back.
func MemPlane ¶
func MemPlane() Plane
MemPlane is the plane with nothing of its own: a map from address to ferry.Value, with no serialization format, no I/O and no key function beyond the identity.
ferrytest.RoundTrip(t, ferrytest.MemPlane(), proofs, ferry.WithRegistry(reg))
It is what a codec author proves a registered type against, and it is where ferry's value-fidelity guarantee is visible, because it is the only plane that adds nothing between the value and what comes back.
It carries all six kinds. Each call to the returned Plane's Open mints an empty plane, and the read and write halves it hands back share one set of contents. There is no Golden, because a plane with no format has no spelling to pin.
Four properties to rely on rather than fields to set: it keys by the canonical rendering of an address, it never folds case and never normalises a name, it refuses a duplicate write loudly rather than overwriting, and it enumerates in address order rather than in Go's map order.
It is the wrong plane to prove ferry's key-collision rule with, and that is worth knowing rather than a defect. A plane keying by the canonical rendering can never make two addresses collide, so a run against this one says nothing about the check a flattening driver has to make.
type Proof ¶
type Proof interface {
// Name labels the proof in a report. It is prose for a human and is not
// how a proof is identified.
Name() string
// Type is the Go type this proof discharges, and it is what [Complete]
// joins on. Two proofs may share a [Proof.Name] and mean different types,
// so the name is never the key.
Type() reflect.Type
// contains filtered or unexported methods
}
Proof is what one Go type's round trip through ferry has been shown to be, and Type is the only way to make one.
It is three columns and not one, because none of the three is derivable from the other two: the values to try, the equality relation those values come back under, and the boundary ferry.Value each of them must produce. Drop the third and a codec that writes a durable representation nobody wants still passes, because whatever it writes it reads back.
A registrant writes one per type they register, and CoreTypes is the set ferry writes for its own types.
The interface carries an unexported method, so Type is the only source of one. That is what lets the suites grow the methods they need without breaking every proof outside this repository.
func CoreTypes ¶
func CoreTypes() []Proof
CoreTypes is ferry's own supported type set, discharged: nineteen rows and 58 cases, each row carrying the equality relation its type comes back under and each case carrying the boundary ferry.Value ferry must produce for it.
ferrytest.RoundTrip(t, ferrytest.MemPlane(), ferrytest.CoreTypes())
A driver author runs it through Driver rather than directly. A codec author appends their own proofs to it, which is why this is a function returning a fresh slice rather than a variable:
proofs := append(ferrytest.CoreTypes(), mine...)
It is a published artefact and not a test fixture ¶
The third column is what ends up in every user's config files, key-value stores and secret backends, and it is the only thing their stored data consists of. Changing one of these strings breaks that data while the Go API stays stable, so no tool in the Go toolchain can see it: apidiff, go vet, gofmt and a consumer's own round-trip test all report nothing across a release that moves a duration from "30s" to a nanosecond count.
So a change to a row here is a major version of the module that owns it, and it ships with a written migration. Editing one is not editing a test.
Every row carries its type's zero value, its extremes and the values that historically break it. For floats that is 0, -0, 0.1, 1.0/3.0, the largest and the smallest non-zero magnitude, both infinities and NaN; for integers the zero and both bounds of the width; for strings the empty string, an embedded NUL, non-UTF-8 bytes and text containing a separator; for composites nil, empty, and one containing an empty element.
func Type ¶
Type builds a proof: a name for reports, the equality relation this type comes back under, and the cases.
ferrytest.Type("time.Time", time.Time.Equal,
ferrytest.At(when, ferry.String("2026-08-02T12:00:00Z")),
)
ferrytest.Type("netip.Addr", ferrytest.Eq[netip.Addr],
ferrytest.At(netip.Addr{}, ferry.String("")),
ferrytest.At(netip.MustParseAddr("192.0.2.1"), ferry.String("192.0.2.1")),
)
The relation is required and there is no default, because the two defaults that suggest themselves are both wrong: reflect.DeepEqual is false for a round-tripped time.Time, because of its monotonic reading, and false for any struct holding NaN, and == is wrong for the same time.Time. A harness that defaulted would report failures that are not failures, and the obvious repair is to loosen the comparison until it stops complaining. Eq, BitEq, SliceEq, MapEq and PtrEq cover the ordinary shapes; anything else supplies its own func.
Inference resolves T from the relation, so Type("int", Eq[int], ...) needs no explicit instantiation, and time.Time.Equal is already a func of exactly the required signature.
The cases are load-bearing and this is exactly as good as them. A lossy float codec measured against a four-value row was caught by one of the four, so carry the zero value, both extremes, and the values that historically break the type.
type T ¶
T is what a suite in this package reports to. Pass *testing.T; it satisfies this for free, with no adapter and no wrapper, and so do *testing.B and *testing.F.
It is two methods rather than *testing.T so that a suite is runnable from a probe or a main, and so that a caller who wants to assert a driver fails a case can capture the report instead of failing their own run.
Helper is required rather than optional. Without it every failure a suite reports is attributed to a line inside this package, and a driver author reading their own CI output learns nothing about which of their cases went red.
type Want ¶
Want is one failure a call is expected to report: the address it happened at, and the class it belongs to.
ferrytest.Want{Address: ferry.At("db", "port"), Class: ferry.ErrValue}
Class is matched with errors.Is, so a subordinate sentinel is a narrower expectation than the class above it: ferry.ErrReadOnly matches only a failure that declares it, where ferry.ErrPlane matches that one and every other plane failure beside it. A Want with no Class matches nothing and is reported as the mistake it is.
The zero Address is a value rather than a wildcard. It is the address of a failure that has none, such as a plane that would not close, so a Want that leaves it out matches only such a failure.