Skip to content

How to Test Code That Uses Configuration

The pkg/config package is built for testability: unlike a bare *viper.Viper, the Containable interface can be mocked, and reader-backed containers let you feed config from an in-memory string. 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

Use NewReaderContainer with a YAML string — no files on disk:

func TestMyFunction(t *testing.T) {
    fs := afero.NewMemMapFs()

    testConfigYAML := `
app:
  name: "test-app"
  debug: true
  port: 8080
database:
  host: "localhost"
  port: 5432
`

    container := config.NewReaderContainer(fs,
        config.WithConfigFormat("yaml"),
        config.WithConfigReaders(strings.NewReader(testConfigYAML)),
    )

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

Use the generated mocks

GTB ships auto-generated mocks (via mockery) in mocks/pkg/config. Prefer these over hand-written fakes — they are generated from the real interfaces (MockContainable, MockObservable, MockEmbeddedFileReader), so they stay in sync and verify expectations on cleanup.

import (
    "testing"

    "gitlab.com/phpboyscout/go-tool-base/mocks/pkg/config"
    "github.com/stretchr/testify/assert"
)

func TestWithProvidedMocks(t *testing.T) {
    mockConfig := config.NewMockContainable(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.
}

Mock a nested section by returning another mock from Sub():

func TestConfigSubSection(t *testing.T) {
    mockConfig := config.NewMockContainable(t)
    mockSub := config.NewMockContainable(t)

    mockConfig.EXPECT().Sub("database").Return(mockSub)
    mockSub.EXPECT().GetString("host").Return("localhost")

    assert.Equal(t, "localhost", mockConfig.Sub("database").GetString("host"))
}

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 — invoke Run(cfg) directly.

Observer logic, with a mock config:

func TestLogLevelObserver(t *testing.T) {
    mockConfig := config.NewMockContainable(t)
    mockConfig.EXPECT().GetString("log.level").Return("debug")

    called := false
    observer := &LogLevelObserver{
        onLevelChange: func(level string) { called = true; assert.Equal(t, "debug", level) },
    }

    require.NoError(t, observer.Run(mockConfig))
    assert.True(t, called)
}

Registration + integration, with a reader container:

func TestObserverRegistration(t *testing.T) {
    container := config.NewReaderContainer(afero.NewMemMapFs(),
        config.WithConfigFormat("yaml"),
        config.WithConfigReaders(strings.NewReader("log:\n  level: \"info\"\n")),
    )

    called := false
    container.AddObserverFunc(func(cfg config.Containable) error {
        called = true
        if cfg.GetString("log.level") == "" {
            return errors.New("log level not configured")
        }
        return nil
    })

    // Reader containers don't watch files — run the observers directly.
    for _, observer := range container.GetObservers() {
        require.NoError(t, observer.Run(container))
    }
    assert.True(t, called, "observer should have been called")
}

Error handling — assert the observer rejects bad config:

func TestObserverErrorHandling(t *testing.T) {
    container := config.NewReaderContainer(afero.NewMemMapFs(),
        config.WithConfigFormat("yaml"),
        config.WithConfigReaders(strings.NewReader("log:\n  level: \"invalid_level\"\n")),
    )

    container.AddObserverFunc(func(cfg config.Containable) error {
        valid := []string{"debug", "info", "warn", "error"}
        if level := cfg.GetString("log.level"); !slices.Contains(valid, level) {
            return fmt.Errorf("invalid log level %q, must be one of: %v", level, valid)
        }
        return nil
    })

    var gotErr error
    for _, observer := range container.GetObservers() {
        if err := observer.Run(container); err != nil {
            gotErr = err
        }
    }
    require.Error(t, gotErr)
    assert.Contains(t, gotErr.Error(), "invalid log level")
}

Inspect and debug configuration in a test

When values aren't resolving as expected, dump the live state:

container.Dump(os.Stdout)            // all values as JSON to stdout
configJSON := container.ToJSON()     // JSON string for structured logging

// Drop to the underlying viper for advanced introspection:
allSettings := container.GetViper().AllSettings()

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