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
WatchConfiggoroutines. - Using it in
TestMainort.Cleanupmakes 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.