Skip to content

How to Test Code That Uses Configuration

The go/config Store is built for testability: reads go through the config.Reader interface (which the published mocks implement), and a store can be fed entirely from an in-memory document — no files on disk. This guide covers the common recipes. For general test scaffolding (test Props, filesystem mocking, race avoidance), see How to Test Components; for the design, see the Config component.

Build a test configuration in memory

Feed a store from a named in-memory source and pin a view:

func TestMyFunction(t *testing.T) {
    store, err := config.NewStore(t.Context(),
        config.WithReaders(config.NamedSource{Name: "test", Content: []byte(`
app:
  name: "test-app"
  debug: true
  port: 8080
database:
  host: "localhost"
  port: 5432
`)}),
    )
    require.NoError(t, err)

    result := MyFunctionThatNeedsConfig(store.View())
    assert.Equal(t, "expected", result)
}

A *config.Store satisfies props.Props.Config directly, so the same construction backs a full test Props. When a test needs a writable layer (anything exercising Apply, or provenance checks that distinguish user-authored files from embedded defaults), declare a real file layer instead:

fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "config.yaml", []byte("log:\n  level: info\n"), 0o600))

store, err := config.NewStore(t.Context(),
    config.WithFiles(configafero.Wrap(fs), "config.yaml"))

Inside this repository, internal/testutil wraps both recipes as StoreFromYAML / ViewFromYAML and FileStoreFromYAML / FileViewFromYAML.

Use the generated mocks

The config module publishes mockery-generated mocks in go/config/mocks. Prefer these over hand-written fakes — they are generated from the real interfaces (MockReader, MockObservable, MockBinder), stay in sync, and verify expectations on cleanup. A MockReader is the right double for any function that takes config.Reader:

import (
    "testing"

    configmocks "gitlab.com/phpboyscout/go/config/mocks"
    "github.com/stretchr/testify/assert"
)

func TestWithProvidedMocks(t *testing.T) {
    mockConfig := configmocks.NewMockReader(t)

    mockConfig.EXPECT().GetString("database.host").Return("test-host")
    mockConfig.EXPECT().GetInt("database.port").Return(5432)
    mockConfig.EXPECT().Has("database.ssl").Return(true)
    mockConfig.EXPECT().GetBool("database.ssl").Return(false)

    service := NewDatabaseService(mockConfig)
    assert.NoError(t, service.Connect())
    // Expectations are verified automatically on cleanup.
}

Typed-section consumers (config.UnmarshalSection, generated Validate<Name>Config functions) also take config.Reader, so they stay mockable: expect SectionExists and drive UnmarshalKey with RunAndReturn to populate the target struct.

Test observer behaviour

Observers often carry critical logic (restarting services, changing log levels) and signal validation errors via their returned error. You don't need file watching — drive a reload deliberately.

Observer logic in isolationRun takes config.Observed, and a store satisfies the read surface, so hand it a pinned snapshot however you like and call it directly.

Registration + reload, with a mutable source: give the store a backend whose content the test can replace, then call Reload:

type mutableSource struct {
    mu      sync.Mutex
    content []byte
}

func (m *mutableSource) ID() string                          { return "test.yaml" }
func (m *mutableSource) Capabilities() config.Capabilities   { return config.Capabilities{} }
func (m *mutableSource) Set(yaml string)                     { m.mu.Lock(); m.content = []byte(yaml); m.mu.Unlock() }
func (m *mutableSource) Load(ctx context.Context, _ []config.Layer) ([]config.Layer, error) {
    m.mu.Lock()
    content := m.content
    m.mu.Unlock()

    return config.NewReaderBackend("test.yaml", content).Load(ctx, nil)
}

func TestObserverFiresOnReload(t *testing.T) {
    src := &mutableSource{content: []byte("log:\n  level: info\n")}
    store, err := config.NewStore(t.Context(), config.WithBackend(src))
    require.NoError(t, err)

    var got string
    store.AddObserverFunc(func(cfg config.Observed) error {
        got = cfg.GetString("log.level")
        return nil
    })

    src.Set("log:\n  level: debug\n")
    require.NoError(t, store.Reload(t.Context()))
    assert.Equal(t, "debug", got, "the observer sees the snapshot that triggered it")
}

An unchanged reload does not notify — observers fire only when the resolved configuration actually changed. Inside this repository the mutable-source recipe is available as testutil.MutableStoreFromYAML.

Inspect and debug configuration in a test

When values aren't resolving as expected, ask the store where a value came from — provenance is first-class:

view := store.View()

fmt.Println(view.Explain("database.host")) // which layer won, and who was shadowed
snapshot := store.Snapshot()
allValues := snapshot.Values()             // the fully-resolved tree
layers := snapshot.Layers()                // every loaded layer with its Source

Resolution precedence when a value surprises you: changed flags → env → config files (later files override earlier) → embedded defaults. For schema-based validation see Validate configuration; for general runtime issues see the Troubleshooting Guide.