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.

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:
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
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")
}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]+: # #'--- PASS: TestLoad
--- PASS: TestLoadUnset
--- PASS: TestRestored
recovered: testing: test using t.Setenv or t.Chdir can not use t.Parallel
--- PASS: TestParallelPanicsTestLoad 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.


