Skip to content
gohacks.dev
Testing

Test Code That Reads Environment Variables in Go Safely

Use t.Setenv to test Go code that reads environment variables: it restores the old value after the test, and it refuses to run in parallel tests.

4 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Configuration read from environment variables is easy to write and easy to test wrongly. The usual mistake is os.Setenv("PORT", "9090") in a test and no cleanup, so every test that runs afterwards in the same package sees PORT=9090. The failure depends on test order, which means it shows up on a colleague's machine and not yours.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

t.Setenv exists to prevent exactly that. This is the function being tested:

config.goGo
package main

import (
	"os"
	"strconv"
)

type Config struct {
	Port  int
	Debug bool
}

// Load reads PORT (default 8080) and DEBUG (set and non-empty means on).
func Load() Config {
	c := Config{Port: 8080}
	if v, ok := os.LookupEnv("PORT"); ok {
		if n, err := strconv.Atoi(v); err == nil {
			c.Port = n
		}
	}
	c.Debug = os.Getenv("DEBUG") != ""
	return c
}

func main() {}

Load uses both LookupEnv, which tells apart "not set" from "set to empty", and Getenv, which does not. That difference matters in a moment.

Set it, and let Go put it back

config_test.goGo
package main

import (
	"os"
	"testing"
)

func TestLoad(t *testing.T) {
	t.Setenv("PORT", "9090")
	t.Setenv("DEBUG", "1")
	if got := Load(); got != (Config{Port: 9090, Debug: true}) {
		t.Fatalf("got %+v", got)
	}
}

func TestLoadUnset(t *testing.T) {
	// Setenv then Unsetenv: t.Setenv registers the restore, os.Unsetenv clears it.
	t.Setenv("PORT", "")
	os.Unsetenv("PORT")
	t.Setenv("DEBUG", "")
	os.Unsetenv("DEBUG")
	if got := Load(); got != (Config{Port: 8080}) {
		t.Fatalf("got %+v", got)
	}
}

func TestRestored(t *testing.T) {
	if v, ok := os.LookupEnv("PORT"); ok {
		t.Fatalf("PORT leaked: %q", v)
	}
}

func TestParallelPanics(t *testing.T) {
	defer func() { t.Logf("recovered: %v", recover()) }()
	t.Parallel()
	t.Setenv("PORT", "1")
}
Shell
go test -count=1 -v -run 'Load|Restored|ParallelPanics' . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
--- PASS: TestLoad
--- PASS: TestLoadUnset
--- PASS: TestRestored
    recovered: testing: test using t.Setenv or t.Chdir can not use t.Parallel
--- PASS: TestParallelPanics

TestLoad sets two variables and checks the result. When it ends, the testing package restores each variable to what it was before: the old value if there was one, or unset if there was not. TestRestored proves it by looking for PORT with os.LookupEnv and failing if it exists. It passes, because the environment is back to normal.

Testing "not set"

t.Setenv can only set a value, and setting "" is not the same as unset. LookupEnv returns ("", true) for an empty variable, so a test that wants the default path has to remove the variable. The pattern in TestLoadUnset is to call t.Setenv(name, "") first, which saves the original value and registers the restore, and then os.Unsetenv(name). When the test ends the cleanup puts back whatever was there before, so the unset is also undone.

If you skip the first call and only call os.Unsetenv, a developer who has PORT exported in their shell will lose it for the rest of the test run, and a different test may then fail.

Why it refuses parallel tests

The last test deliberately breaks the rule. A process has one environment, shared by every goroutine in it. If two parallel tests each set PORT to a different value, they overwrite each other and the results depend on timing. So t.Setenv panics in a test that is, or whose parent is, marked with t.Parallel, and the recorded message is testing: test using t.Setenv or t.Chdir can not use t.Parallel.

That is a design signal rather than an inconvenience. Code that reads the environment from deep inside business logic is hard to test in parallel. The better shape is the one in Load: read the environment once, at the edge of the program, into a struct, and pass the struct to everything else. Tests of the rest of the program then construct a Config directly and can run in parallel. Keep a small number of sequential tests for Load itself.

A few things to keep in mind. Set the variable inside the test, not in TestMain, unless it is truly global for the package. Prefer t.Setenv to a helper that wraps os.Setenv and defer, because the cleanup order with subtests is handled for you. And when a test needs a temporary working directory as well, t.TempDir and t.Chdir follow the same restore-after-test rule, with the same restriction on t.Parallel.

The behaviour is documented on testing.T.Setenv. For the general shape of table tests that call a function like Load many times, see table driven tests.

Also inBasics
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.