TestMain — Find Bug¶
Each snippet has at least one bug related to TestMain. Spot the bug; the fix follows. These are real bugs from real code review. Internalize the pattern; the next one you encounter will look slightly different.
Bug 1 — defer + os.Exit¶
Bug. db.Close is never called. os.Exit skips deferred functions.
Fix:
Or the helper-function pattern:
func TestMain(m *testing.M) {
os.Exit(run(m))
}
func run(m *testing.M) int {
db := openDB()
defer db.Close()
return m.Run()
}
Bug 2 — ignored exit code¶
Bug. The exit code is discarded. CI thinks the suite passed even when tests fail.
Fix: os.Exit(m.Run()) (or rely on Go 1.15+ implicit forwarding by writing code := m.Run() is still wrong if code is discarded — use os.Exit(m.Run()) for clarity).
Bug 3 — flag read before parse¶
var dbURL = flag.String("dburl", "memory://", "")
func TestMain(m *testing.M) {
fmt.Println("Using DB:", *dbURL)
os.Exit(m.Run())
}
Bug. Output always prints memory:// regardless of -dburl=....
Fix: call flag.Parse() first:
Bug 4 — duplicate TestMain¶
Two files in the same package each define func TestMain(m *testing.M). The build fails with duplicate function TestMain.
Fix: keep exactly one. If both files have setup needs, fold them into a single function and call helpers.
Bug 5 — wrong signature¶
Bug. The test binary treats it as a normal test (because the name starts with Test), not as a lifecycle hook.
Fix: parameter must be *testing.M, not *testing.T:
Bug 6 — calling m.Run twice¶
Bug. Second m.Run panics or no-ops depending on Go version. Either way, useless.
Fix: one m.Run per process.
Bug 7 — setup goroutine race¶
var data []string
func TestMain(m *testing.M) {
go func() {
data = loadData()
}()
os.Exit(m.Run())
}
Bug. Tests start running before loadData completes. Read of data races with the write.
Fix: do the work synchronously, or wg.Wait() before m.Run:
Bug 8 — log to file with no flush¶
Bug. f.Close() is never called; os.Exit skips defers; buffered writes may be lost.
Fix: code := m.Run(); f.Close(); os.Exit(code).
Bug 9 — TestMain in a non-test file¶
Bug. go test does not see it (not in a _test.go file), and the production binary gets a confusingly named function.
Fix: move to main_test.go.
Bug 10 — environment leak across tests¶
Bug. Any test that wants to verify "what if APP_MODE is unset" cannot, because the env was set process-wide. The leak is within the same binary, not across processes.
Fix: prefer t.Setenv inside individual tests when scope is per-test. If truly process-wide, document it and have tests that need a different value t.Setenv to override.
Bug 11 — coverage misconfiguration¶
Bug. go test -cover reports 0% coverage because no test functions exist. TestMain is not a test.
Fix: add at least one TestXxx, even an empty one, or accept the report.
Bug 12 — calling os.Exit inside a test¶
Bug. Skips teardown registered in TestMain and other tests.
Fix: t.Fatal(err) and let TestMain handle teardown.
Bug 13 — flag definition after flag.Parse¶
Bug. Flag is defined too late; -foo=bar on the command line was already rejected during flag.Parse.
Fix: define flags as package-level variables in a _test.go file, never after flag.Parse.
Bug 14 — test that depends on TestMain order¶
var counter int
func TestMain(m *testing.M) {
counter = 0
os.Exit(m.Run())
}
func TestA(t *testing.T) { counter++ }
func TestB(t *testing.T) {
if counter != 1 {
t.Fatal("TestA must run first")
}
}
Bug. Tests have no defined execution order. go test -shuffle=on will break it.
Fix: each test sets up its own state, or use a single test with subtests.
Bug 15 — leaking container¶
func TestMain(m *testing.M) {
container := startPostgres()
code := m.Run()
if code != 0 {
os.Exit(code) // leaks container on failure
}
container.Terminate(context.Background())
os.Exit(code)
}
Bug. On failure, the container is never terminated.
Fix: terminate before the exit, unconditionally:
func TestMain(m *testing.M) {
container := startPostgres()
code := m.Run()
container.Terminate(context.Background())
os.Exit(code)
}
Bug 16 — log.Fatal in setup¶
Bug (subtle). log.Fatal calls os.Exit(1), which is technically fine for the immediate purpose, but it also emits a stack trace that adds noise and prevents you from running any teardown.
Fix: prefer a one-line message and explicit exit:
Bug 17 — incorrect return type assumption¶
Bug. Signature wrong; TestMain must return nothing. The compiler may not flag it if the generated test main expects a func(*testing.M). In some Go versions this builds as a regular function and is ignored.
Fix: correct signature:
Bug 18 — relying on init order between test files¶
// file: a_test.go
var a *Thing
func init() { a = newThing() }
// file: b_test.go
var b *Thing
func init() { b = newThingDependentOn(a) }
Bug. Go does not guarantee init order across files in the same package beyond top-to-bottom within each file and alphabetical for files. b's init may run before a's, observing nil.
Fix: do the dependent setup in TestMain where the order is explicit:
Bug 19 — recovering panics and swallowing them¶
func TestMain(m *testing.M) {
defer func() {
if r := recover(); r != nil {
// swallowed; tests appear to pass
}
}()
os.Exit(m.Run())
}
Bug. A panic in setup (before m.Run) is silently swallowed, the program exits 0, CI thinks the suite passed.
Fix: at minimum re-emit and exit non-zero:
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "panic: %v\n", r)
os.Exit(1)
}
}()
Bug 20 — assuming t.Parallel affects TestMain¶
A developer writes:
func TestMain(m *testing.M) {
setup()
setup2() // expected to run in parallel with setup()
os.Exit(m.Run())
}
Bug. setup and setup2 run sequentially. There is no t.Parallel available in TestMain. Calling them concurrently requires explicit goroutines or errgroup.
Fix:
g, _ := errgroup.WithContext(context.Background())
g.Go(setup)
g.Go(setup2)
if err := g.Wait(); err != nil { /* ... */ }
Bug 21 — TestMain in _test.go of an external test package, depends on internal symbol¶
// file: mypkg_test.go
package mypkg_test
import "example.com/mypkg"
func TestMain(m *testing.M) {
mypkg.internalSetup() // not exported
os.Exit(m.Run())
}
Bug. internalSetup is unexported and unreachable from mypkg_test. Build fails.
Fix: either export it (rename InternalSetup), or move TestMain into package mypkg (white-box).
Bug 22 — forgetting os.Exit after manual code path¶
Bug. Since Go 1.15 returning normally exits with the result of m.Run, if the variable goes through. But here we never used code, so the runtime forwards 0 always. Subtle.
Actually re-check: Go 1.15+ forwards the result of m.Run regardless of whether you assigned it — the runtime hooks into the M itself. So this might be safe. But the explicit os.Exit(code) is clearer and works on all Go versions.
Fix: explicit is better:
These bugs cover the bulk of what surfaces in real review. Internalize the deferred-os.Exit interaction and the flag-parse rule, then add the rest as muscle memory. Make a habit, when reviewing a TestMain you have not written, of asking five questions:
- Is
flag.Parse()called before any flag read? - Is
m.Run()called exactly once? - Is the exit code propagated via
os.Exit? - Do all setup resources have corresponding teardown that does not rely on
deferafteros.Exit? - Are there goroutines started in
TestMainthat need to be joined?
If you can answer yes to all five, the TestMain is probably correct.