How to Use t.Setenv for Environment Variables in Tests

Use t.Setenv to temporarily set environment variables in Go tests with automatic cleanup.

The environment variable leak

You write a test for a configuration loader. It reads APP_PORT from the environment, sets it to 9090, runs the loader, and asserts the result. The test passes. You run the entire suite. Suddenly, a test for the database connection fails. The error message complains about connecting to port 9090 instead of the expected default. The environment variable leaked. One test changed a global state, and the change persisted into the next test.

Environment variables live in the process, not the function. Changing one affects every goroutine and every test running in that process. t.Setenv solves this by scoping the change to the test function. It saves the current value, sets the new one, and restores the old value automatically when the test finishes. It handles panics too. If your test crashes, the variable still gets restored.

How t.Setenv works

t.Setenv is a wrapper around t.Cleanup. When you call t.Setenv(key, value), the testing package performs a sequence of steps. It reads the current value of key from the environment. It stores that value along with a flag indicating whether the key existed before the call. It sets the new value immediately. Finally, it registers a cleanup function.

The cleanup function runs after the test body completes. It runs regardless of whether the test passed, failed, or panicked. The cleanup function checks the flag. If the key existed before, it restores the saved value. If the key was missing, it deletes the key entirely using os.Unsetenv.

This mechanism makes t.Setenv safe against panics. If you use os.Setenv directly and the test panics, the cleanup code never runs. The variable stays modified. t.Setenv guarantees restoration because the testing framework calls cleanup functions even during panic recovery.

Minimal example

Here's the simplest usage: set the variable at the top of the test and let the testing framework handle the rest.

// TestSetenvBasic demonstrates scoped environment variable setting.
func TestSetenvBasic(t *testing.T) {
    // t.Setenv registers a cleanup function that restores the variable.
    t.Setenv("DEBUG", "true")

    // The variable is available immediately to the process.
    val := os.Getenv("DEBUG")
    if val != "true" {
        t.Fatalf("expected DEBUG to be true, got %q", val)
    }
}

t.Setenv is insurance. Buy it every time.

Cleanup order matters

Cleanup functions registered via t.Setenv run in reverse order. If you set multiple variables, the last one set is the first one restored. This rarely causes issues, but it matters if you have custom cleanup logic that depends on the environment state.

// TestSetenvOrder demonstrates cleanup execution order.
func TestSetenvOrder(t *testing.T) {
    // First set registers first.
    t.Setenv("A", "1")

    // Second set registers second.
    t.Setenv("B", "2")

    // Cleanup runs in reverse: B restores, then A restores.
    // This invariant holds even if the test panics.
}

The testing framework manages the stack of cleanup functions. You don't need to track order manually. Just set variables in the order your test needs them.

Realistic configuration test

Real code often loads configuration from the environment. Here's a function that reads settings and a test that overrides them safely. The function handles parsing and defaults.

// Config holds application settings loaded from environment variables.
type Config struct {
    Port int
    Mode string
}

// LoadConfig reads settings from the environment.
func LoadConfig() Config {
    port := 8080
    if p := os.Getenv("APP_PORT"); p != "" {
        // Parse port safely; ignore error for brevity in example.
        if parsed, err := strconv.Atoi(p); err == nil {
            port = parsed
        }
    }

    mode := "production"
    if m := os.Getenv("APP_MODE"); m != "" {
        mode = m
    }

    return Config{Port: port, Mode: mode}
}

The test uses t.Setenv to provide specific values. Each call to t.Setenv registers its own cleanup. The test verifies that the loader respects the environment.

// TestLoadConfig verifies configuration loading with custom environment values.
func TestLoadConfig(t *testing.T) {
    // Set multiple variables; each gets its own cleanup.
    t.Setenv("APP_PORT", "9090")
    t.Setenv("APP_MODE", "testing")

    cfg := LoadConfig()

    if cfg.Port != 9090 {
        t.Errorf("expected port 9090, got %d", cfg.Port)
    }
    if cfg.Mode != "testing" {
        t.Errorf("expected mode testing, got %s", cfg.Mode)
    }
}

Functions that read os.Getenv are hard to test in isolation. t.Setenv makes them testable by allowing you to control the input. The convention in Go is to keep environment reading at the boundary of your application. Load config once, then pass the Config struct to functions. This follows the "accept interfaces, return structs" spirit: accept concrete configuration, return results.

Suite-wide setup with TestMain

Sometimes every test needs the same environment variable. Setting it in every test is repetitive. TestMain lets you run code before and after the entire test suite. It's useful for setting variables that mock a service or enable verbose logging for all tests.

// TestMain runs suite-wide setup and teardown.
func TestMain(m *testing.M) {
    // Set suite-wide variables before tests start.
    os.Setenv("SUITE_VAR", "value")

    // Run tests and capture exit code.
    code := m.Run()

    // Restore or clean up suite variables.
    os.Unsetenv("SUITE_VAR")

    // Exit with the test result code.
    os.Exit(code)
}

TestMain receives a *testing.M. Calling m.Run() executes all tests and returns an exit code. You must call os.Exit(code) at the end. If you forget, the test runner might report incorrect results. Use TestMain for suite-wide setup. Use t.Setenv for test-specific overrides.

TestMain runs once. Use it for suite-wide setup.

Pitfalls and race conditions

Environment variables are process-wide. If you run tests in parallel with t.Parallel(), t.Setenv becomes dangerous. Two tests might set the same variable at the same time. One test reads the value while the other test's cleanup restores it. You get flaky failures that appear and disappear randomly.

The compiler won't catch this. The race detector might not catch it either because os.Setenv and os.Getenv use internal locking. The race is in the logic, not the memory access. Parallel tests and global state don't mix.

If you need parallel tests, refactor the code to accept configuration as a parameter instead of reading environment variables directly. Pass a Config struct to functions. This removes the dependency on global state and makes tests deterministic.

// BadExample shows manual cleanup that fails on panic.
func BadExample(t *testing.T) {
    // os.Setenv does not register cleanup.
    os.Setenv("LEAK", "value")

    // If this line panics, LEAK stays set forever.
    panic("oops")
}

The compiler rejects code with syntax errors, but it won't warn about missing cleanup. If you pass the wrong type to t.Setenv, the compiler complains with cannot use 123 (untyped int constant) as string value in argument. t.Setenv requires two strings. Always pass strings.

Another pitfall is assuming t.Setenv affects child processes. It does. Environment variables are inherited by subprocesses. If your test spawns a process, that process sees the modified variable. This is usually desired behavior, but be aware of it when testing CLI tools.

The worst goroutine bug is the one that never logs. The worst test bug is the one that only fails in CI. t.Setenv prevents the latter by ensuring isolation.

Decision matrix

Use t.Setenv when you need to override an environment variable for a single test and want automatic restoration. Use t.Cleanup with os.Setenv when you need complex logic to restore state, such as modifying a file or database alongside environment changes. Use TestMain when you need to set variables for the entire test suite before any tests run. Avoid os.Setenv in test bodies when you want isolation; manual cleanup is easy to forget and breaks on panics. Avoid t.Setenv in parallel tests when the variable is shared; parallel tests race on process-wide state.

Where to go next