Skip to content
gohacks.dev
Testing

Hermetic Tests in Go and What Quietly Breaks the Seal

A hermetic Go test depends only on its inputs. See four hidden ones, the time zone, home and working directories and the network, and how to seal them.

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

"It works on my machine" is a statement about a test's hidden inputs. The test did not depend on the code alone; it depended on the time zone, the user, the directory it was started in, a file that happens to exist, a network that happens to be up. A hermetic test has none of these. Everything it needs comes in through the test, and the result is the same on a laptop, in CI and in a container two years from now.

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

Hermetic is the strongest form of the properties that flaky test hunting and the test cache both rely on. This article shows how to detect a leak with a repeatable experiment on Go 1.25.6, lists the usual suspects, and says how far to take it.

An experiment that finds leaks

env.goGo
package main

import (
	"os"
	"strings"
	"time"
)

// Describe depends on things a test should not: the time zone, the home directory, the working directory.
func Describe() string {
	t := time.Date(2026, 1, 15, 23, 30, 0, 0, time.UTC).Local()
	home, _ := os.UserHomeDir()
	wd, _ := os.Getwd()
	return strings.Join([]string{t.Format("2006-01-02 15:04"), home, wd[strings.LastIndex(wd, "/")+1:]}, " | ")
}

func main() {}

Describe is written to leak. It converts a UTC instant with .Local(), reads the home directory, and prints the name of the working directory. Any test that calls it depends on three things nobody wrote down.

env_test.goGo
package main

import "testing"

func TestDescribe(t *testing.T) {
	t.Logf("%s", Describe())
}
Shell
go test -c -o t.bin . >/dev/null 2>&1
show() { echo "$1"; shift; "$@" ./t.bin -test.v -test.run Describe 2>&1 | grep -E '^ +env_test' | sed -E 's/^ +env_test.go:[0-9]+: /  /'; }
show "# your shell"           env TZ=UTC HOME=/home/dev
show "# another time zone"    env TZ=Asia/Tokyo HOME=/home/dev
show "# another home"         env TZ=UTC HOME=/home/ci
mkdir -p other-dir && cd other-dir && cp ../t.bin . && echo "# another working directory" && env TZ=UTC HOME=/home/dev ./t.bin -test.v -test.run Describe 2>&1 | grep -E '^ +env_test' | sed -E 's/^ +env_test.go:[0-9]+: /  /'
Text
# your shell
  2026-01-15 23:30 | /home/dev | hm
# another time zone
  2026-01-16 08:30 | /home/dev | hm
# another home
  2026-01-15 23:30 | /home/ci | hm
# another working directory
  2026-01-15 23:30 | /home/dev | other-dir

The method is the point. Compile the test once with go test -c, then run the binary under changed environments and compare the output. If a hidden input matters, the output differs.

  • Under TZ=UTC the instant 23:30 on 15 January prints as 2026-01-15 23:30.
  • Under TZ=Asia/Tokyo the same instant prints as 2026-01-16 08:30: a different time and a different date. A test asserting on the date passes in London and fails in Tokyo, and a CI runner in another region can fail with no change to the code.
  • With HOME changed from /home/dev to /home/ci, the second field changes. A test reading the home directory, for a config file or a cache, uses a different place for every user.
  • From another working directory the last field changes. A test that opens testdata/x.json works because go test sets the working directory to the package; the compiled binary run from elsewhere does not, which also shows how fragile relative paths are outside that guarantee.

A hermetic test gives identical output in all four runs. This one does not, which is the finding.

The usual suspects

Hidden inputSymptomSeal it by
Time zone and localeDates shift, sorting order differsUse UTC or an explicit *time.Location; avoid .Local() and time.Now() in assertions
Current timeBoundary failures at midnight, month endsInject a clock; see determinism
Environment variablesDifferent config per machinet.Setenv, or pass values in; see setenv
Home, config, cache directoriesReads or writes the developer's real filesSet HOME and XDG_* to t.TempDir()
Working directoryRelative paths breakUse testdata/ and avoid os.Chdir; t.Chdir when unavoidable
Files outside the repositoryPasses where the file existsCopy fixtures into testdata/
The networkFails offline, slow, flakyhttptest, fakes, net.Pipe; see RoundTripper fakes
DNSResolves differently inside a containerUse 127.0.0.1 and port 0
Installed toolsTest shells out to git, dockerSkip with a clear message when absent, or fake the command
PortsCollisions in parallel runsListen on :0
Random numbers and map orderDifferent path every runSeed and sort; see seeds
Test order and shared statePasses alone, fails together-shuffle, per-test state
Machine resourcesTimeouts under load, GOMAXPROCSGenerous guards; wait on events

Most of that list is covered by habits that appear elsewhere in this series; the two that deserve a closer look are the home directory and the network.

The home directory

Many libraries read and write under the user's home: os.UserConfigDir, os.UserCacheDir, ~/.netrc, ~/.gitconfig. A test that runs them touches the developer's real files. The seal is to point the base variables at a temp directory for the test: t.Setenv("HOME", t.TempDir()), and on Linux XDG_CONFIG_HOME and XDG_CACHE_HOME; on Windows USERPROFILE and APPDATA. A test that shells out to git should also set GIT_CONFIG_GLOBAL=/dev/null so the developer's configuration does not change behaviour. This is the leak that also makes a test destructive: one that writes ~/.cache/app can corrupt the cache of a real install.

The network

The only fully reliable network seal is to have none, and Go does not enforce it. A discipline that works: all HTTP goes through an http.Client that tests replace with a fake transport, and a test helper installs a default transport that fails loudly on any call (http.DefaultTransport = failTransport{} in TestMain, restored at the end). An accidental real call then fails on your machine, not just on the offline runner. For stronger isolation, run the tests in a container with --network none, or with Linux network namespaces, where a leak becomes an immediate error.

How hermetic is hermetic enough

There is a cost curve. The first three rows, time zone, clock and environment, are cheap and remove most of the machine-dependence. Home directory and network discipline are small habits. Full isolation, where the test binary runs in a sandbox with no network, a read-only file system and a fixed clock, is what build systems like Bazel provide. Most Go projects do not need it. Aim for the point where the experiment above shows no differences and a clean container with no network runs the unit tests.

Integration tests are the exception by design: they depend on a database or a container, and the aim there is controlled, not absent, dependence. Make the dependency explicit, start it from the test, pin its version, and reset its state, as in testcontainers. Keep them in a separate tier, as the integration tag article describes, so the unit tier stays sealed.

A check you can automate

The experiment is scriptable. A CI job can build the test binary for each package and run it twice: once in the normal environment, and once under a hostile one: TZ=Pacific/Kiritimati (UTC+14), LANG=C, HOME set to an empty directory, no network, a different working directory. A package whose tests pass in one and fail in the other has a leak, and the failure message names the test. This catches new leaks when they are introduced, and it is cheaper than finding them in a runner in another time zone.

The testing package documents the working directory behaviour in its package overview, and the environment helpers t.Setenv and t.TempDir in the T reference.

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.