Files

3.6 KiB

Viper Test Isolation

The global state problem

The top-level viper.* functions operate on a global *viper.Viper instance shared across all tests in the same process. Tests that call viper.SetConfigFile, viper.Set, or viper.ReadInConfig pollute this global state, causing flaky test ordering.

// ✗ Bad — sets global state that affects later tests
func TestPortConfig(t *testing.T) {
    viper.SetDefault("port", 8080)
    viper.Set("port", 9090)
    assert.Equal(t, 9090, viper.GetInt("port"))
    // global viper now has port=9090 for all subsequent tests
}

viper.New() per test (correct approach)

func TestPortConfig(t *testing.T) {
    v := viper.New()
    v.SetDefault("port", 8080)
    v.Set("port", 9090)
    assert.Equal(t, 9090, v.GetInt("port"))
}

func TestDefaultPort(t *testing.T) {
    v := viper.New()
    v.SetDefault("port", 8080)
    assert.Equal(t, 8080, v.GetInt("port"))  // clean — not affected by TestPortConfig
}

Injecting viper into your app

For test isolation to work, your application code must accept a *viper.Viper instead of calling the global functions directly:

// ✓ Good — accepts a viper instance
type Server struct {
    cfg *viper.Viper
}

func NewServer(v *viper.Viper) *Server {
    return &Server{cfg: v}
}

func (s *Server) Port() int {
    return s.cfg.GetInt("port")
}

// In tests:
func TestServer(t *testing.T) {
    v := viper.New()
    v.Set("port", 9090)
    s := NewServer(v)
    assert.Equal(t, 9090, s.Port())
}

// In main:
func main() {
    // viper setup...
    s := NewServer(viper.GetViper())  // pass the global instance in production
}

Reading config files in tests

func TestReadConfig(t *testing.T) {
    v := viper.New()
    v.SetConfigFile("testdata/config.yaml")
    require.NoError(t, v.ReadInConfig())
    assert.Equal(t, "localhost", v.GetString("host"))
}

Use testdata/ for config files. Go test tooling sets the working directory to the package directory, so relative paths work reliably.

t.Setenv interactions

t.Setenv sets an env var for the duration of a test and restores it on cleanup. Combined with viper.New() + AutomaticEnv, this lets you test env var binding without global pollution:

func TestEnvBinding(t *testing.T) {
    t.Setenv("MYAPP_PORT", "9090")

    v := viper.New()
    v.SetEnvPrefix("MYAPP")
    v.AutomaticEnv()

    assert.Equal(t, 9090, v.GetInt("port"))
    // t.Setenv restores original MYAPP_PORT (or unsets it) after this test
}

viper.Reset() — use with caution

viper.Reset() resets the global viper instance to its zero state. It is rarely the right solution:

  • It affects all code running concurrently that also uses the global viper.
  • It does not stop any active WatchConfig goroutines.
  • Using it in TestMain or t.Cleanup makes tests order-dependent.

Prefer viper.New() per test. Reserve Reset() for tools that call into viper-based libraries and must restore state between runs.

Snapshot and restore pattern

When you cannot refactor to inject *viper.Viper and must use the global:

func snapshotViper() map[string]interface{} {
    return viper.AllSettings()
}

func restoreViper(snapshot map[string]interface{}) {
    viper.Reset()
    for k, v := range snapshot {
        viper.Set(k, v)
    }
}

func TestWithGlobalViper(t *testing.T) {
    snapshot := snapshotViper()
    t.Cleanup(func() { restoreViper(snapshot) })

    viper.Set("port", 9090)
    // test code...
}

This approach is fragile — AllSettings() only captures the resolved values, not the binding state (defaults, env bindings, etc.). Prefer injection.