Testing
mamoritest is an in-memory, scriptable mamori.Provider for testing code that consumes mamori (an OnChange handler, an OnError sink) without a real AWS account, Vault, or Kubernetes cluster. Its wait helpers block until a change is actually applied instead of sleeping and hoping.
go get github.com/xavidop/mamori/mamoritest
Quick start
Create a provider, script a value, Watch it, then push a change and wait for it to land before asserting:
func TestConfigReactsToChange(t *testing.T) {
p := mamoritest.NewProvider("mt")
p.Set("cfg/level", "info")
type Config struct {
Level string `source:"mt://cfg/level"`
}
w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p))
if err != nil {
t.Fatalf("Watch: %v", err)
}
defer func() { _ = w.Close() }()
if w.Get().Level != "info" {
t.Fatalf("Level = %q, want info", w.Get().Level)
}
p.Set("cfg/level", "debug") // push a change
mamoritest.WaitForSnapshot(t, w, 2) // block until it lands (1 = initial load)
if w.Get().Level != "debug" {
t.Fatalf("Level = %q, want debug", w.Get().Level)
}
}
Provider implements mamori.Provider and mamori.WatchableProvider, so it works with Load, Watch, and Doctor like a real provider. Register it with mamori.WithProvider and reference keys with source:"<scheme>://<key>".
Drive the fake provider
Five methods script what the provider returns for a key:
func (p *Provider) Set(key, val string)
func (p *Provider) SetBytes(key string, b []byte)
func (p *Provider) Del(key string)
func (p *Provider) Fail(key string, err error)
func (p *Provider) Clear(key string)
| Method | Effect |
|---|---|
Set | Store a string value and push it to any watcher of key. |
SetBytes | Byte-oriented Set, for binary payloads. |
Del | Remove the value; later resolves and watchers see mamori.ErrNotFound. |
Fail | Every future resolve of key returns err (and pushes it) until Clear. |
Clear | Remove an injected failure and push the restored state. |
keymust match the ref’s path exactly:source:"mt://cfg/level"is keyed bySet("cfg/level", ...), not the fullmt://...URL.- A key with neither
SetnorFailcalled on it resolves asmamori.ErrNotFound. - After
Del, a field withdefault:oroptionalfalls back to its default; a required field with no default becomes an unhealthy, erroring field.
Wait for async propagation
A push from Set, Del, or Fail reaches a real mamori.Watcher asynchronously on the reconciler’s goroutine. These helpers block until the effect is observable, so a test never needs a fixed time.Sleep.
Wait for a change to land
func WaitForSnapshot[T any](tb testing.TB, w *mamori.Watcher[T], v uint64)
Blocks until w.Status().Snapshot reaches version v, then returns; fails via tb.Fatalf if v never arrives in time. The initial load is version 1, the first applied change is 2, the next is 3, and so on.
p.Set("db/password", "hunter2")
w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p))
// ... err check, defer w.Close()
p.Set("db/password", "rotated") // one change since Watch started
mamoritest.WaitForSnapshot(t, w, 2)
Wait for an error
func CaptureErrors() (mamori.Option, *ErrorCapture)
func WaitForError(tb testing.TB, c *ErrorCapture, kind mamori.Kind) error
CaptureErrors returns an OnError sink option plus the *ErrorCapture it feeds; pass the option to Watch or Load. WaitForError blocks until the capture holds an error classified as kind (via mamori.ErrorKind) and returns it, else fails the test. See Error kinds for the Kind values.
onErr, errs := mamoritest.CaptureErrors()
w, err := mamori.Watch[Config](context.Background(), mamori.WithProvider(p), onErr)
// ... err check, defer w.Close()
p.Fail("cfg/level", mamori.ErrPermissionDenied)
got := mamoritest.WaitForError(t, errs, mamori.KindPermissionDenied)
A complete test
Rotating a value and asserting both Get() and the OnChange handler, then failing a key and asserting OnError reaches it with the expected kind:
package myapp_test
import (
"context"
"testing"
"time"
"github.com/xavidop/mamori"
"github.com/xavidop/mamori/mamoritest"
)
type Config struct {
DBPassword string `source:"mt://db/password"`
}
func TestRotationTriggersOnChange(t *testing.T) {
p := mamoritest.NewProvider("mt")
p.Set("db/password", "hunter2")
// OnChange runs on its own goroutine; signal through a channel.
changed := make(chan string, 1)
w, err := mamori.Watch[Config](context.Background(),
mamori.WithProvider(p),
mamori.OnChange(func(ev mamori.Change[Config]) {
changed <- ev.New.DBPassword
}),
)
if err != nil {
t.Fatalf("Watch: %v", err)
}
defer func() { _ = w.Close() }()
p.Set("db/password", "rotated")
mamoritest.WaitForSnapshot(t, w, 2) // 1 = initial load, 2 = first change
// WaitForSnapshot guarantees Get() already reflects the rotated value.
if w.Get().DBPassword != "rotated" {
t.Fatalf("Get().DBPassword = %q, want rotated", w.Get().DBPassword)
}
// OnChange may land just after the snapshot; give it a bounded wait.
select {
case got := <-changed:
if got != "rotated" {
t.Fatalf("OnChange saw DBPassword = %q, want rotated", got)
}
case <-time.After(2 * time.Second):
t.Fatal("OnChange did not fire after Set")
}
}
func TestBackendFailureReachesOnError(t *testing.T) {
p := mamoritest.NewProvider("mt")
p.Set("db/password", "hunter2")
onErr, errs := mamoritest.CaptureErrors()
w, err := mamori.Watch[Config](context.Background(),
mamori.WithProvider(p), onErr)
if err != nil {
t.Fatalf("Watch: %v", err)
}
defer func() { _ = w.Close() }()
p.Fail("db/password", mamori.ErrPermissionDenied)
got := mamoritest.WaitForError(t, errs, mamori.KindPermissionDenied)
if mamori.ErrorKind(got) != mamori.KindPermissionDenied {
t.Fatalf("WaitForError returned kind %q, want permission_denied", mamori.ErrorKind(got))
}
// The watcher keeps serving the last good value; a failure never blanks it.
if w.Get().DBPassword != "hunter2" {
t.Fatalf("Get().DBPassword after Fail = %q, want last-good hunter2", w.Get().DBPassword)
}
}
Fail the build when config cannot resolve
Doctor resolves every field of T once against your real provider wiring and reports every failure without starting a watcher. Run it as a build-tagged CI test that fails on any unhealthy field, so a rotated-away secret, a missing IAM permission, or a typo’d ref is caught before it ships:
//go:build preflight
func TestConfigPreflight(t *testing.T) {
rep, err := mamori.Doctor[Config](context.Background(), appProviders()...)
if err != nil {
t.Fatal(err)
}
for _, f := range rep.Fields {
if f.LastKind != "" {
t.Errorf("%s (%s): %s: %s", f.Path, f.Ref, f.LastKind, f.LastError)
}
}
}
Gate it behind the tag so it only runs where real credentials and network access exist:
go test -tags preflight ./...
Unlike Load, Doctor never aborts on the first failure, so one run reports every misconfigured ref. See Doctor for the full Report shape.
Drive poll intervals with the fake clock
mamoritest.Provider delivers changes natively, not on a poll timer. To drive time itself (for example, testing mamori.WithPollInterval against a provider that only polls), inject mamori.NewFakeClock with mamori.WithClock and advance it manually:
clk := mamori.NewFakeClock(time.Time{})
w, err := mamori.Watch[Config](context.Background(),
mamori.WithProvider(p),
mamori.WithClock(clk),
mamori.WithPollInterval(30*time.Second),
)
// ... err check, defer w.Close()
clk.Advance(30 * time.Second) // fires the next poll tick deterministically
How it works
A Watch against the fake delivers changes over a native Go channel, not by polling. Each Watch replays the key’s current state as an immediate baseline and delivers a delete as ErrNotFound (not a channel close), mirroring real native-watch backends (Kubernetes informers, Firestore, Redis keyspace notifications). The subscription is torn down when the context is done, leaving no goroutine behind (goleak-checked).
The snapshot versions WaitForSnapshot uses are the reconciler’s own: Watcher.Status().Snapshot starts at 1 for the initial load and increments by one per applied change, so the first change lands on 2. NewFakeClock and WithClock live in the core mamori package, not mamoritest, since they are a general-purpose seam for any time-dependent test.
See also
- Write a provider covers
providertest, the counterpart kit for provider authors. - Doctor covers
Doctorand theReportshape in full. - Error kinds lists the
KindvaluesWaitForErrormatches against.