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.

"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
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.
package main
import "testing"
func TestDescribe(t *testing.T) {
t.Logf("%s", Describe())
}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]+: / /'# 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-dirThe 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=UTCthe instant 23:30 on 15 January prints as2026-01-15 23:30. - Under
TZ=Asia/Tokyothe same instant prints as2026-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
HOMEchanged from/home/devto/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.jsonworks becausego testsets 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 input | Symptom | Seal it by |
|---|---|---|
| Time zone and locale | Dates shift, sorting order differs | Use UTC or an explicit *time.Location; avoid .Local() and time.Now() in assertions |
| Current time | Boundary failures at midnight, month ends | Inject a clock; see determinism |
| Environment variables | Different config per machine | t.Setenv, or pass values in; see setenv |
| Home, config, cache directories | Reads or writes the developer's real files | Set HOME and XDG_* to t.TempDir() |
| Working directory | Relative paths break | Use testdata/ and avoid os.Chdir; t.Chdir when unavoidable |
| Files outside the repository | Passes where the file exists | Copy fixtures into testdata/ |
| The network | Fails offline, slow, flaky | httptest, fakes, net.Pipe; see RoundTripper fakes |
| DNS | Resolves differently inside a container | Use 127.0.0.1 and port 0 |
| Installed tools | Test shells out to git, docker | Skip with a clear message when absent, or fake the command |
| Ports | Collisions in parallel runs | Listen on :0 |
| Random numbers and map order | Different path every run | Seed and sort; see seeds |
| Test order and shared state | Passes alone, fails together | -shuffle, per-test state |
| Machine resources | Timeouts under load, GOMAXPROCS | Generous 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.


