Go testing packages for the Multigres projects.
Three packages, one module:
assertis a typed assertion vocabulary for ordinary unit tests. No Kubernetes, no controller-runtime; stdlib andgo-cmponly.goldencompares a value's YAML marshalling against a checked-in file, for tests that would otherwise assert field-by-field against a literal struct. Rewrite the files with-golden.update. Also a leaf: stdlib,go-cmpandsigs.k8s.io/yaml, and notably nottesting.ctrltestis a multi-controller test harness built onenvtest: write attribution per controller, reconcile interception, a closed-world step script, quiescence detection and a core-kind data plane simulator.
They ship together because ctrltest hangs its own vocabulary on the same
receiver assert provides, so a test says c.Eq(...) and c.Get(key, obj)
against one object. Importing assert alone is supported and costs you none of
the harness.
v0, and expect it to stay there for a while. Under SemVer that means any
release may break you, which is an honest description rather than a
disclaimer: this is a young harness whose shape is still being decided by the
suites using it.
go get github.com/multigres/testkit@latest
go install github.com/multigres/testkit/tools/assertfix@latest
tools/assertfix is a separate module and carries its own tags, prefixed with
its directory (tools/assertfix/v0.1.0). A root v0.1.0 says nothing about
it, which is the usual surprise with nested modules.
Go 1.27. assert's comparisons are generic methods, which the language
gained in 1.27, so the floor is not optional: setting a lower go directive
and running go mod tidy rewrites it back.
What the generics buy is a compile-time check on the comparison itself.
c.Eq(1, int64(1)) // does not compilectrltest additionally requires controller-runtime v0.25.1 or newer,
stated here rather than left to arrive as a transitive surprise inside
somebody's dependency bump.
Running ctrltest's own tests needs the envtest binaries. make setup-envtest
fetches them into ./bin; make test does it for you.
Equality, errors, nil, length and emptiness, containment over slices and
strings, ordered comparisons, zero values, pointer identity, unordered slice
equality, panics, file and directory existence, and polling with Eventually.
The set was chosen by counting what the test suites in these projects actually
call, so it is sized to real use rather than to completeness. That is also the
bar for adding to it: show the call sites.
Two omissions are deliberate rather than pending:
- Type-coercing equality. An assertion that accepts
1againstint64(1)gives up the compile-time check that the generic signatures exist for. Convert explicitly at the call site instead. IsTypeandImplements. Generics turned these into compile-time concerns, so there is nothing left for a runtime assertion to add.
Assertions abort by default, which matches C's assert and Go's own
t.Fatal. Neither constructor is spelled New: the two modes are used about
equally across these projects, so making either the unmarked default would
mean every reader of a call site had to remember which it was.
c := assert.NewAborting(t) // first failure ends the test
c := assert.NewCollecting(t) // failures accumulate
c.Require().NoError(err) // flip one call to aborting
c.Check().Eq(want, got) // flip one call to collectingEvery positive assertion has its negative, which is why NotErrorIs and
NotEqDiff exist alongside ErrorIs and EqDiff. NotEq is not the
negation of a deep comparison: two distinct pointers to identical structs
pass it and fail NotEqDiff.
Nil and NotNil take any rather than a type parameter, because Go cannot
express "nilable" as a constraint. They look through to the dynamic value, so
an interface holding a nil pointer reads as nil here, where != nil reports
it as non-nil and everything downstream then dereferences it. Len, Empty
and NotEmpty take any for the same reason.
Every reconciler under test needs a substitution seam in its production
source. ctrltest wraps the reconcile boundary to attribute writes, gate by
namespace and compress requeues, which means the controller must accept a
reconciler other than its receiver:
func (r *T) SetupWithManager(mgr ctrl.Manager) error {
return r.SetupWithManagerReconciler(mgr, r)
}
func (r *T) SetupWithManagerReconciler(mgr ctrl.Manager, reconciler reconcile.Reconciler) error {
// ... builder configuration ...
return b.Complete(reconciler)
}This cannot be avoided by a cleverer harness. The receiver has to stay live on the enqueue path, because predicates and map functions bind to it when the builder runs, so the same pointer must be both the receiver and whatever the substitute delegates to. Wrapping from outside would mean reimplementing the builder configuration in the test, where it would drift from production.
It is about fifteen lines per reconciler. A controller that cannot take the seam still gets write attribution and the data plane simulator, but not gating, requeue compression or reconcile records.
Options.Managers takes one entry per operator, and the reason to need two is
usually not scale. Two operators that both know a CRD might register different
Go types for it, if the consuming one wants a few status fields rather than a
dependency on the whole module, and runtime.Scheme refuses a second Go type
for a kind it already holds. Where that happens they cannot share a manager,
which is also how they run in production.
suite, teardown, err := ctrltest.Boot(ctrltest.Options{
Scheme: harnessScheme,
CRDPaths: []string{aCRDs, bCRDs},
WatchedKinds: []client.ObjectList{ /* ... */ },
Managers: []ctrltest.ManagerOptions{
{Name: "a", Scheme: aScheme, CacheOptions: aCache, Register: registerA},
{Name: "b", Scheme: bScheme, CacheOptions: bCache, Register: registerB},
},
})The recorder and the interceptor stay suite-wide, which is what makes running
operators together useful: the op log is one interleaved timeline, quiescence
means every operator has settled, and an assertion can name what one operator
did in response to another. Both key attribution by the controller name passed to
Register, so name controllers per operator or two of them will be credited
with each other's writes.
Reads are the one thing that cannot be shared, since a Go type in one manager's
scheme is not resolvable by another's client. A case reads through the harness
scheme by default and crosses over with c.Via("b").
make help # every target
make check # lint and test this module, the pre-push gate
make check-all # plus the tools module, which is everything CI runs bar race
make test # envtest-backed, -p 1
make test-race
tools/assertfix is a separate Go module, deliberately: it needs
golang.org/x/tools, and nothing importing testkit/assert should inherit an
analysis framework. The consequence is that the root ./... does not reach
it, so it has its own targets (make check-tools) and its own CI job.
golangci-lint is pinned at 2.13.2 or newer, and that floor is load bearing:
2.12.2 panics on Linux under Go 1.27 before it lints anything. See the comment
on the lint target.
See CONTRIBUTING.md. Commits are Conventional Commits and
must be signed off (git commit -s); the DCO check is required.
Apache 2.0. Every source file carries // SPDX-License-Identifier: Apache-2.0,
and make license-check fails if one does not.