Test Configuration Loading in Go the Easy Way With Fixtures
Test Go config loading with fixture files in testdata and t.Setenv: defaults, a file, environment overrides, a bad number, broken JSON and a missing file.

Configuration code has a short list of behaviours and a long list of ways to get them wrong: the precedence between defaults, files and environment variables; what happens when a value is malformed; what the error says. A table test covers all of it in a few lines, with sample files kept in testdata.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
)
type Config struct {
Addr string `json:"addr"`
Workers int `json:"workers"`
}
// Load reads the JSON file at path, then lets APP_ADDR and APP_WORKERS override it.
func Load(path string) (Config, error) {
cfg := Config{Addr: ":8080", Workers: 4}
if path != "" {
b, err := os.ReadFile(path)
if err != nil {
return cfg, fmt.Errorf("read config: %w", err)
}
if err := json.Unmarshal(b, &cfg); err != nil {
return cfg, fmt.Errorf("parse config: %w", err)
}
}
if v := os.Getenv("APP_ADDR"); v != "" {
cfg.Addr = v
}
if v := os.Getenv("APP_WORKERS"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("APP_WORKERS: %w", err)
}
cfg.Workers = n
}
return cfg, nil
}
func main() {}package main
import "testing"
func TestLoad(t *testing.T) {
cases := []struct {
name string
file string
env map[string]string
}{
{"defaults", "", nil},
{"file", "testdata/full.json", nil},
{"env overrides file", "testdata/full.json", map[string]string{"APP_ADDR": ":1", "APP_WORKERS": "2"}},
{"bad number", "", map[string]string{"APP_WORKERS": "many"}},
{"broken file", "testdata/broken.json", nil},
{"missing file", "testdata/nope.json", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for k, v := range c.env {
t.Setenv(k, v) // restored when the subtest ends
}
cfg, err := Load(c.file)
t.Logf("cfg=%+v err=%v", cfg, err)
})
}
}go test -count=1 -v . 2>&1 | grep -vE "^(=== RUN|=== PAUSE|=== CONT|PASS|ok)" | sed -E "s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #" cfg={Addr::8080 Workers:4} err=<nil>
cfg={Addr::8080 Workers:4} err=read config: open testdata/full.json: no such file or directory
cfg={Addr::8080 Workers:4} err=read config: open testdata/full.json: no such file or directory
cfg={Addr::8080 Workers:4} err=APP_WORKERS: strconv.Atoi: parsing "many": invalid syntax
cfg={Addr::8080 Workers:4} err=read config: open testdata/broken.json: no such file or directory
cfg={Addr::8080 Workers:4} err=read config: open testdata/nope.json: no such file or directory
--- PASS: TestLoad
--- PASS: TestLoad/defaults
--- PASS: TestLoad/file
--- PASS: TestLoad/env_overrides_file
--- PASS: TestLoad/bad_number
--- PASS: TestLoad/broken_file
--- PASS: TestLoad/missing_fileFixtures in testdata
The directory testdata has a special meaning to the go tool: it is ignored when building packages, so anything in it is invisible to the compiler and available to tests by relative path. A test runs with the package directory as its working directory, so testdata/full.json resolves. The lab has two files, one valid and one truncated. Keeping real sample files beside the tests also documents the format.
Precedence and errors
The log shows the rules. With no file and no environment, the defaults apply: Addr::8080 Workers:4. A valid file replaces them with :9000 and 8. With the same file and both variables set, the environment wins: :1 and 2. That ordering is the contract, and the third row proves it.
The error rows matter more than they look. APP_WORKERS=many returns APP_WORKERS: strconv.Atoi: parsing "many": invalid syntax, with the variable's name first, so an operator can fix the right setting. A configuration error is read by a person at the worst time, and a test that logs the message is a test of its quality. Assert the substring that identifies the problem, not the whole text, so wording changes do not break it.
Tests that hold the contract
A configuration loader is read by every other part of the program, and the cases above are its contract: which source wins, what the defaults are, what a failure says. Pin each one. When someone adds a new source, such as a command line flag, they add a row to the table that states where it sits in the order. When someone changes a default, a row fails and the change is made on purpose. Without the table, a default can change in a refactor and take effect in production before anyone has read the diff.
Two more rows are worth having: an empty string for a variable, which usually means unset but sometimes means a deliberate blank, and a file that parses but has an unknown key, which you may want to reject with DisallowUnknownFields so that a typo in a config file is an error and not a silent default.
t.Setenv
t.Setenv(k, v) sets the variable and registers a cleanup that restores the previous value, so each subtest starts clean. It panics in a parallel test, because the environment is process-wide; the parallel tests article shows the message. The longer treatment is in the setenv note. If config loading is slow or needs the real environment in many tests, accept a lookup func(string) string parameter instead of calling os.Getenv, and tests can pass a map lookup and run in parallel.
The testdata convention is described in the go command documentation, and the file reading side is os.ReadFile in the os package.


