Test with the config mocks¶
Code that reads configuration should take a config.Reader, not a concrete *Store —
so a test can pass a mock, assert exactly which keys are read, and drive specific
return values without building a real configuration file. The module ships generated
testify mocks in the mocks package.
What is in the package¶
| Mock | Mocks | Use it when |
|---|---|---|
MockReader |
config.Reader |
testing anything that only reads configuration |
MockObserved |
config.Observed |
testing an observer's Run in isolation |
MockObservable |
config.Observable |
asserting that your wiring registered an observer, or that it ran |
MockBinder |
config.Binder |
testing a typed section without a real Store |
MockBackend |
config.Backend |
testing a component that consumes a custom source |
MockWritableBackend |
config.WritableBackend |
as above, for one that can persist |
MockWatchableBackend |
config.WatchableBackend |
as above, for one that reports its own changes |
config.Watcher is deliberately not mocked. It is a one-method interface, and a
test usually wants to hold the trigger rather than script a return value — write the
four-line stub shown in
testing reload behaviour.
Each has a NewMockX(t) constructor that registers cleanup asserting every
expectation was met, so a read your code was supposed to perform and did not fails the
test.
Depend on the interface¶
// production code takes the interface, not the store
func NewServer(cfg config.Reader) *Server {
return &Server{addr: cfg.GetString("server.host"), port: cfg.GetInt("server.port")}
}
Do not hand-write a Reader
Reader is broad — thirty methods, covering every accessor, provenance and both
unmarshal entry points — and it grows as typed accessors are added. A hand-written
fake has to be updated every time, for no benefit. Use mocks.MockReader, or a real
store over an in-memory filesystem.
If your component only needs two values, the better fix is to take those two values
rather than a Reader at all.
*config.View satisfies config.Reader, so the wiring passes store.View() and the
test passes a mock.
Use the published mock¶
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"gitlab.com/phpboyscout/go/config"
"gitlab.com/phpboyscout/go/config/mocks"
)
func TestNewServer(t *testing.T) {
cfg := mocks.NewMockReader(t) // fails the test on unexpected calls
cfg.EXPECT().GetString("server.host").Return("localhost")
cfg.EXPECT().GetInt("server.port").Return(8080)
srv := NewServer(cfg)
assert.Equal(t, "localhost", srv.addr)
assert.Equal(t, 8080, srv.port)
}
Reach for this when the keys read are the thing under test. It is the only way to
assert that a component reads server.host and nothing else, which is a genuine
regression risk when someone adds a stray lookup.
Mocking a config.Value[T] read¶
Value[T] is a function over Reader, and it reads through UnmarshalKey — so that is
what to expect, writing the result through the pointer it is handed:
cfg.EXPECT().UnmarshalKey("log.level", mock.Anything).
Run(func(_ string, target any) {
*(target.(*Severity)) = Debug
}).
Return(nil)
If the keys read are not what your test is about, a real store over an in-memory file is less work than this — see when you want real behaviour.
Test an observer's logic¶
An observer is handed a config.Observed, so MockObserved drives it directly — no
watcher, no files, no timing:
cfg := mocks.NewMockObserved(t)
cfg.EXPECT().GetString("log.level").Return("debug")
require.NoError(t, (&levelWatcher{}).Run(cfg))
Observers often carry the riskiest logic in a service — restarting listeners, changing
log levels — and this is the cheapest way to test it. For the reload machinery itself,
see hot-reload, which drives change detection
with a stub Watcher.
MockObserved.Sub returns a *config.View, not another mock, because Sub is
concrete on the read surface. Return nil for an absent key, or a real view built
with config.NewView(snapshot) when the code under test descends into a subtree.
Test a typed section without a store¶
ObserveSection takes a config.Binder, and MockBinder is one. This lets you assert
that a component binds the section it claims to, and capture the observer it registers
so you can fire it yourself:
// The two configurations this test moves between. Real stores over in-memory
// files are less work here than mocking a whole Reader.
source := storeOver(t, "server:\n port: 8080\n") // what the binding starts with
next := storeOver(t, "server:\n port: 9090\n") // what a later reload supplies
var observer func(config.Observed) error
binder := mocks.NewMockBinder(t)
binder.EXPECT().View().Return(source.View())
binder.EXPECT().AddObserverFunc(mock.Anything).
Run(func(fn func(config.Observed) error) { observer = fn }).
Return()
settings, err := config.ObserveSection[Server](binder, "server")
require.NoError(t, err)
require.Equal(t, 8080, settings.Value().Port)
// Later: simulate a reload with whatever configuration you like.
require.NoError(t, observer(next.View()))
require.Equal(t, 9090, settings.Value().Port)
storeOver is a two-line helper, and Server is your own settings struct:
type Server struct {
Port int `mapstructure:"port"`
}
func storeOver(t *testing.T, body string) *config.Store {
t.Helper()
fsys, err := config.Dir(t.TempDir())
require.NoError(t, err)
require.NoError(t, fsys.WriteFile("/config.yaml", []byte(body), 0o600))
store, err := config.NewStore(context.Background(), config.WithFiles(fsys, "/config.yaml"))
require.NoError(t, err)
return store
}
When you want real behaviour¶
If what you are testing is the configuration behaviour itself — merging, precedence,
provenance, decoding — do not mock. Build a real Store from in-memory readers, which
needs no filesystem at all:
store, err := config.NewStore(ctx,
config.WithReaders(
config.NamedSource{Name: "defaults", Content: []byte("server:\n port: 8080\n")},
config.NamedSource{Name: "overlay", Content: []byte("server:\n port: 9090\n")},
),
)
require.NoError(t, err)
assert.Equal(t, 9090, store.View().GetInt("server.port"))
For file behaviour — watching, writing, missing overlays — use config.Dir(t.TempDir()),
which gives you real merge and write behaviour with nothing on disk:
fsys, err := config.Dir(t.TempDir())
require.NoError(t, err)
require.NoError(t, fsys.WriteFile("/app.yaml", []byte("server:\n port: 8080\n"), 0o644))
store, err := config.NewStore(ctx, config.WithFiles(fsys, "/app.yaml"))
require.NoError(t, err)
Note that fsnotify cannot see an in-memory filesystem, so Watch falls back to
polling there — which is why a test that needs deterministic change detection should
supply its own Watcher rather than rely on either.
Use a mock to test consumers of configuration; use a real store to test configuration itself.
Test the environment without touching the process¶
Process environment is global state, so mutating it makes parallel tests interfere with
each other. WithEnviron supplies the variables instead:
store, err := config.NewStore(ctx,
config.WithFiles(fsys, "/app.yaml"),
config.WithEnv("MYTOOL", config.WithEnviron(func() []string {
return []string{"MYTOOL_SERVER_PORT=9090"}
})),
)
Check which observers were registered¶
store.Observers() returns the registered observers, which exists for exactly this —
asserting that your wiring code registered what it should:
Debugging a config surprise¶
Three questions cover nearly every "why is this value what it is?":
view := store.View()
fmt.Println(view.Explain("server.port")) // the whole provenance chain
fmt.Println(view.Shadowed("server.port")) // every layer defining it, lowest first
fmt.Println(store.Sources()) // every backend, in precedence order
view.Keys() enumerates every leaf path, and store.Snapshot().Values() returns the
merged configuration as a plain map — a copy, so printing or mutating it cannot affect
the store. Remember that precedence is simply the order the sources were added, so
Sources() is usually enough to explain a surprise on its own.