- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen the failure. A test suite that passes on your machine and
passes in CI, until one day a new test lands in the same package and
an unrelated test three files over starts failing. Nobody touched it.
The diff has nothing to do with it. You run go test ./... again and
it passes. You run it a fourth time and it fails again.
Then somebody runs go test -shuffle=on and the whole thing falls
apart. Different tests fail on different seeds.
That is the signature of package-level state. A var at the top of a
file, an init() that wired something up once, a cache that one test
filled and the next test read. Go makes this easy to write and hard to
see, and it turns your tests into a suite that depends on the order the
test binary happens to run them in.
Where the global hides
Package-level state is any value that lives outside a function and
persists for the life of the process. The obvious version is a plain
var:
package billing
var rates = map[string]float64{}
func SetRate(cur string, r float64) {
rates[cur] = r
}
func Convert(cur string, amt float64) float64 {
return amt * rates[cur]
}
rates is one map for the whole process. Every test that calls
SetRate mutates the same map. Every test that calls Convert reads
whatever the last test left behind. Two tests, run in either order,
and one of them sees state it never set up.
The less obvious version is a singleton built once and cached:
package db
var conn *sql.DB
func Get() *sql.DB {
if conn == nil {
conn = mustOpen()
}
return conn
}
The first test that calls Get decides which database every later
test in the package talks to. If that test pointed conn at a fake,
the real integration test downstream quietly runs against the fake and
passes for the wrong reason.
init() is the quietest offender
init() runs once, before main, before any test. You never call it,
so it never shows up in a stack trace you are reading. That is exactly
what makes it dangerous.
package config
var Settings = load()
func load() *Config {
f, _ := os.Open("config.yaml")
// parse into a *Config...
return parsed
}
Settings is populated at package-load time by reading a file from
disk. Your test binary reads a config.yaml that happens to sit in the
package directory. Change that file and unrelated tests shift. Delete
it and the whole package fails to initialize. There is no seam to pass
a different config in, because the value was frozen before your test
function ever ran.
init() with an explicit body is the same problem wearing a name:
package metrics
var registry = map[string]int{}
func init() {
registry["requests"] = 0
go startFlusher(registry) // a goroutine, at package load
}
Now package load also spawns a goroutine that mutates registry on a
timer. Your test reads registry["requests"], and the value depends on
how long the test binary has been alive. That is not a test. That is a
race with a clock.
Why the order dependence is invisible until it isn't
Go runs the tests in a single package sequentially, in the order they
appear in the source, within one process. That process keeps every
package-level var alive from the first test to the last. So state
leaks forward in time.
For a while this looks fine. Test A sets rates["USD"], test B reads
it and passes, and you never notice B was relying on A. The suite is
green. The coupling is real but silent.
Two things expose it. First, someone reorders the tests, adds a test
above B, or splits a file. Now B runs before A, rates["USD"] is the
zero value, and B fails with a number nobody can explain. Second,
go test -shuffle=on (stable since Go 1.17) randomizes the order on
purpose. If your suite has hidden ordering coupling, shuffle finds it,
usually in CI, usually on a Friday.
The tell to watch for: a test that passes alone and fails in the suite,
or the reverse. Run the one test in isolation:
go test -run TestConvertUSD -count=1 ./billing
If it passes alone but fails with the package, some other test is
setting up state it depends on. That is package-level state, every
time.
The -count=1 and t.Parallel traps
Two more ways the global bites, both worth knowing.
go test caches results. When nothing in the package changed, a second
run prints (cached) instead of re-running. That is fine until you are
chasing a flaky test and the cache hides the flake. -count=1 forces a
real run and disables the cache. If a test flips between pass and fail
only when you add -count=1, the flake is real and state is the
suspect.
t.Parallel() turns the leak into a data race. Sequential tests
mutating a shared map merely see stale values. Parallel tests mutating
the same map race on it:
func TestA(t *testing.T) {
t.Parallel()
billing.SetRate("USD", 1.0) // writes shared map
}
func TestB(t *testing.T) {
t.Parallel()
billing.Convert("USD", 10) // reads shared map
}
Run that with go test -race and the detector fires on rates. The
race is not in the test. It is in the package-level map the tests are
forced to share because there is no other copy.
The cure: pass state in, don't reach for it
The fix is the same one every time. Stop reaching out to a global.
Take the dependency as a parameter. Put the state inside a struct the
caller constructs.
Rewrite billing so the rates live on a value, not on the package:
package billing
type Converter struct {
rates map[string]float64
}
func NewConverter() *Converter {
return &Converter{rates: map[string]float64{}}
}
func (c *Converter) SetRate(cur string, r float64) {
c.rates[cur] = r
}
func (c *Converter) Convert(cur string, amt float64) float64 {
return amt * c.rates[cur]
}
Now each test builds its own Converter. There is no shared map. There
is nothing to leak forward, nothing to race on, nothing that depends on
which test ran first:
func TestConvert(t *testing.T) {
t.Parallel()
c := billing.NewConverter()
c.SetRate("USD", 2.0)
if got := c.Convert("USD", 10); got != 20 {
t.Fatalf("got %v, want 20", got)
}
}
Every test starts from a known zero. You can add t.Parallel() to all
of them and -race stays quiet, because no two tests touch the same
memory.
Do the same for the database. Instead of a package-level conn behind
a Get(), accept the *sql.DB where you need it:
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
Production main opens the real database once and hands it to
NewUserStore. Tests hand in a test database or a fake. No test can
accidentally inherit another test's connection, because the connection
is a parameter, not a global.
When you can't avoid a global, isolate it in the test
Some globals you do not own. A third-party library keeps package state,
or you inherited a var that the whole codebase reaches for and you
cannot refactor today. When you are stuck with one, make the test
reset it and guard the reset with t.Cleanup:
func TestWithGlobal(t *testing.T) {
saved := legacy.DefaultTimeout
legacy.DefaultTimeout = 5 * time.Second
t.Cleanup(func() {
legacy.DefaultTimeout = saved
})
// ... test body ...
}
t.Cleanup (Go 1.14+) runs after the test finishes, even if it calls
t.Fatal, so the global goes back to what it was. Do not use
t.Parallel() in tests that mutate a shared global this way. Parallel
plus shared mutable state is the race you were trying to escape.
The reset trick is a patch, not a cure. It works, and it keeps one
test from poisoning the next, but it does not remove the coupling. The
real fix is still to turn the global into a parameter so the reset
becomes unnecessary.
The rule
Package-level var and init() are not banned. Genuinely immutable
data, a compiled regex, a lookup table you never write to, is fine as a
package var, because there is nothing to leak. The problem is mutable
state and side effects at package scope.
The test is simple. Ask of every package-level value: can a test change
it, or does it change on its own over time? If yes, it will make your
suite order-dependent, and you should move it onto a struct the caller
constructs. Pass the dependency in. Let each test build its own world
and throw it away. That is the whole discipline, and it is why
dependency injection is worth the extra constructor.
If your Go suite is flaky and you can't say why, run it with
-shuffle=on -race -count=1 and watch what breaks. The failures will
point straight at the globals.
Package-level state is a language-mechanics topic and an architecture
topic at the same time. The Complete Guide to Go Programming digs
into how init(), package initialization order, and the memory model
actually behave at the runtime level, which is where these bugs come
from. Hexagonal Architecture in Go shows how to keep dependencies at
the boundary so state lives on constructed values instead of leaking
into package scope, which is where these bugs stop.

Top comments (0)