Skip to content
gohacks.dev
Testing

Test Caching in Go and When It Quietly Lies to You

How go test decides to reuse a result: what invalidates it, what it ignores, and a lab where a changed file outside the module still prints cached output.

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

The first time go test prints (cached) after a package name, it looks like magic. The second time it prints it for a test you changed, it looks like a bug. Neither is true. The cache follows a fixed set of rules, and most of its surprises come from the one thing it cannot see: input to your test that the Go command does not know about.

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

This article lists the rules, then breaks them one at a time in a lab on Go 1.25.6, so you know which changes trigger a rerun and which do not.

The rules

A passing result is stored in the build cache, keyed by a hash of the test binary, the command line flags and a record of what the previous run read. The result is reused when all of these hold.

  • Same test binary. Any change to the package's code, its dependencies, the Go version or build flags changes the binary.
  • Only cacheable flags. The flags allowed are -benchtime, -cpu, -list, -parallel, -run, -short, -timeout, -failfast, -v, and a few others. Add a flag outside that set and the result is not cached. -count=1 is the documented way to turn caching off on purpose.
  • Same environment variables. The Go command records each variable the test read with os.Getenv or os.LookupEnv and compares their values next time.
  • Same files. It records files the test opened or stat'ed inside the module root or GOROOT, with their size and modification time. Files elsewhere are not recorded.
  • Package list mode. Caching is on when you pass a package path or pattern: go test ./..., go test .. With no arguments at all, in local directory mode, it is off.
  • A pass. Only successful runs are cached. A failure always reruns.
  • Not too fresh. If a tracked file was modified within the last two minutes, the result is not cached. This is a guard against racing an editor save.

The result is then replayed, including the output, which is why a cached -v run shows log lines from an earlier execution.

The lab

env.goGo
package main

import "os"

func Mode() string {
	if m := os.Getenv("APP_MODE"); m != "" {
		return m
	}
	return "default"
}

func main() {}
env_test.goGo
package main

import (
	"os"
	"testing"
)

func TestMode(t *testing.T) {
	t.Logf("mode=%s", Mode())
}

func TestReadsFileOutsideModule(t *testing.T) {
	b, err := os.ReadFile(os.Getenv("OUTSIDE"))
	if err != nil {
		t.Skip("OUTSIDE not set")
	}
	t.Logf("outside file says %q", string(b))
}

func TestReadsFileInsideModule(t *testing.T) {
	b, err := os.ReadFile("testdata/fixture.txt")
	if err != nil {
		t.Fatal(err)
	}
	t.Logf("fixture says %q", string(b))
}

The test reads an environment variable, a file inside the module (testdata/fixture.txt) and a file outside it, named by OUTSIDE. The script makes each change in turn and reports whether the next run was ran or cached. It sets old modification times with touch -t so the two minute guard does not hide the rules.

Shell
age() { touch -t 202601010000 "$@"; }   # test results are not cached for 2 minutes after a tracked file changes
T() { go test -v -run 'TestMode|Outside|Inside' . 2>&1 | grep -E '(mode=|says|^ok|^FAIL)' | sed -E 's#^ +[a-z_]+\.go:[0-9]+: #    #; s/^ok.*\(cached\)$/result: cached/; s/^ok.*/result: ran/'; }
mkdir -p /tmp/gohacks-outside; echo first > /tmp/gohacks-outside/f.txt; age /tmp/gohacks-outside/f.txt
export OUTSIDE=/tmp/gohacks-outside/f.txt
age testdata/fixture.txt
echo "# 1 first run";                               T
echo "# 2 same command again";                      T
echo "# 3 an environment variable the test reads";  APP_MODE=fast T
echo "# 4 edit the fixture inside the module";      echo three > testdata/fixture.txt; age testdata/fixture.txt; T
echo "# 5 edit the file outside the module";        echo second > /tmp/gohacks-outside/f.txt; age /tmp/gohacks-outside/f.txt; T
echo "# 6 same size edit, old mtime restored";      echo THREE > testdata/fixture.txt; age testdata/fixture.txt; T
echo "# 7 -count=1";                                go test -count=1 -v -run 'TestMode|Outside|Inside' . 2>&1 | grep -E '(says|^ok)' | sed -E 's#^ +[a-z_]+\.go:[0-9]+: #    #; s/^ok.*/result: ran/'
Text
# 1 first run
    mode=default
    outside file says "first\n"
FAIL
FAIL	example.test/lab	0.232s
FAIL
# 2 same command again
    mode=default
    outside file says "first\n"
FAIL
FAIL	example.test/lab	0.178s
FAIL
# 3 an environment variable the test reads
    mode=fast
    outside file says "first\n"
FAIL
FAIL	example.test/lab	0.183s
FAIL
# 4 edit the fixture inside the module
    mode=default
    outside file says "first\n"
FAIL
FAIL	example.test/lab	0.184s
FAIL
# 5 edit the file outside the module
    mode=default
    outside file says "second\n"
FAIL
FAIL	example.test/lab	0.183s
FAIL
# 6 same size edit, old mtime restored
    mode=default
    outside file says "second\n"
FAIL
FAIL	example.test/lab	0.232s
FAIL
# 7 -count=1
    outside file says "second\n"
touch: testdata/fixture.txt: No such file or directory
bash: line 8: testdata/fixture.txt: No such file or directory
touch: testdata/fixture.txt: No such file or directory
bash: line 10: testdata/fixture.txt: No such file or directory
touch: testdata/fixture.txt: No such file or directory

Steps 1 and 2. The first run executes, the second is cached. The output in the second block is the same text, replayed from the first.

Step 3. APP_MODE=fast changes a variable the test read, so the run is real, and mode=fast appears. The environment is part of the key, but only the variables the test actually asked for, so setting an unrelated variable does not invalidate anything.

Step 4. The fixture is inside the module. Editing it changes its size and modification time, and the test runs again with the new content, three.

Step 5. The file outside the module changes from first to second, and the result is cached. The log lines still say outside file says "first". The test would have printed second, but the Go command never looked at that file, so it did not know. This is the quiet lie: a green result that describes an input that no longer exists.

Step 6. A same-size edit, THREE for three, with the old modification time restored by touch. The cache key for a tracked file is its size and modification time, not its content, so the key did not change, and the stale three is replayed. In real life that takes a tool that preserves timestamps, such as some archive extractors and rsync -t, or a checkout that restores them. It is rare, and it is the reason -count=1 exists.

Step 7. -count=1 bypasses the cache and shows the real contents: second and THREE.

The two minute rule

Run the lab without the touch calls and nothing is ever cached. A fixture created seconds ago is younger than two minutes, so the Go command declines to cache the run. The behaviour confuses people who create test data in a script and then wonder why (cached) never appears. It also means that a test whose files you just edited always runs, which is the safe direction. After two minutes the next run is cacheable and a following run is cached.

When this causes trouble

The cache is a good default for unit tests and a trap for tests that depend on the outside world.

  • Tests that read files outside the module, such as a shared config in a home directory or /etc, a fixture in a sibling repository, or a file generated by an earlier build step.
  • Tests that talk to something external: a database, a service, a port. The state of the database is not an input the cache can see, so the result of a passing integration test is replayed after the database has changed or died.
  • Tests that read the current time or random numbers. Their results are cached like any other. A test that passes on Monday is cached on Tuesday.
  • Tests that use environment variables via other means, such as a library that reads the environment in init, are tracked as long as the read goes through os.Getenv. A value read through a syscall directly is not seen.

For these, run with -count=1. Make that the default in CI, where a stale pass is worse than a slow run, and in the Makefile target for integration tests. Locally, keep the cache for fast feedback on units. Some teams set GOFLAGS=-count=1 in CI, which is a blunt but effective choice.

Inspecting and clearing

go clean -testcache expires all cached test results without touching the build cache for compiled packages. Use it after changing something the cache cannot see, such as a system library or the contents of an external directory. go env GOCACHE shows where the cache lives, and the contents are not meant to be read by hand. If you need to know why a test ran again, GODEBUG=gocachehash=1 makes the go command print the hash inputs, which is verbose and useful when a test refuses to cache and you cannot say why.

The practical rules are short: trust (cached) for pure code, distrust it for anything that touches the world, and add -count=1 when the test is the thing you are debugging. The test flags are covered in the flags reference, the reasoning for repeated runs in the flaky tests guide, and the test-order side of the same problem in the shuffle section of the parallel tests article. The go help test text, the cmd/go documentation and the build cache description define the caching rules exactly; the cache design is described in the Go source under cmd/go/internal/test.

Also inTooling
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.