TestMain and Setup Teardown in Go Packages Done Right
Use TestMain for package-wide setup and teardown in Go, why os.Exit skips deferred cleanup on failure, flag parsing, test file order and when to avoid it.

Most test setup belongs in the test: a helper builds what it needs and registers a t.Cleanup. A few setups are too expensive to repeat for every test: starting a database container, building a binary to run, loading a large fixture, creating a schema template. Go has one hook for those, TestMain, which runs once for a package and wraps all its tests.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It is a simple feature with a few traps, mostly around exiting, flags and ordering. This article runs a small TestMain, shows what the output looks like in the success and failure paths, and gives guidance on when to reach for it and when a helper is better. It goes with your first test, parallel tests and cleanup. It ran on Go 1.25.6.
The shape of TestMain
If a package's test files declare func TestMain(m *testing.M), the test binary calls it instead of running the tests directly. Inside, you do the setup, call m.Run(), which runs every test, benchmark, fuzz target and example in the package and returns an exit code, and then do the teardown. There can be only one TestMain per package (across all its _test.go files, including those in the external _test package, since they are compiled into one binary).
package main
import (
"flag"
"fmt"
"os"
"testing"
)
var workDir string
// TestMain runs once for the package, around all its tests. Use it for expensive shared setup.
func TestMain(m *testing.M) {
flag.Parse() // needed if setup depends on flags such as -short
fmt.Println("TestMain: setup (short mode:", testing.Short(), ")")
dir, err := os.MkdirTemp("", "suite-*")
if err != nil {
fmt.Println("setup failed:", err)
os.Exit(1)
}
workDir = dir
defer func() {
os.RemoveAll(dir)
fmt.Println("TestMain: teardown ran, directory removed")
}()
code := m.Run() // runs every test and benchmark in the package
fmt.Println("TestMain: m.Run finished with code", code)
if code != 0 {
os.Exit(code) // see the article: this skips the deferred teardown
}
}
func TestSharedDirectory(t *testing.T) {
if _, err := os.Stat(workDir); err != nil {
t.Fatalf("shared directory is missing: %v", err)
}
t.Log("the shared directory exists for every test in the package")
}
func TestAnother(t *testing.T) {
if workDir == "" {
t.Fatal("setup did not run")
}
}package main
import "testing"
func TestInAnotherFile(t *testing.T) {
if workDir == "" {
t.Fatal("setup did not run before this test")
}
}package main
func main() {}The TestMain here parses flags, prints a setup line, creates a temporary directory that the tests share, registers a deferred cleanup, runs the tests and prints the result.
What the run shows
strip() { sed -E 's/[[:space:]]+[0-9.]+s$//; s/ \([0-9.]+s\)//'; }
echo '$ go test -v -short'
go test -count=1 -v -short . 2>&1 | grep -E '^(TestMain|--- |ok|FAIL|PASS)' | strip
echo '--- a failing test, to see the exit path'
cp main_test.go main_test.go.bak
printf '\nfunc TestBroken(t *testing.T) { t.Fatal("broken on purpose") }\n' >> main_test.go
go test -count=1 -v . 2>&1 | grep -E '^(TestMain|--- FAIL|FAIL|ok)' | strip
mv main_test.go.bak main_test.go
echo '--- the two ways to end TestMain'
sed -i.tmp 's/\t\tos.Exit(code) \/\/ see the article: this skips the deferred teardown/\t\treturn/' main_test.go
printf '\nfunc TestBroken(t *testing.T) { t.Fatal("broken on purpose") }\n' >> main_test.go
echo 'returning instead of calling os.Exit, with a failing test:'
go test -count=1 -v . 2>&1 | grep -E '^(TestMain|--- FAIL|FAIL|ok)' | strip$ go test -v -short
TestMain: setup (short mode: true )
--- PASS: TestInAnotherFile
--- PASS: TestSharedDirectory
--- PASS: TestAnother
PASS
TestMain: m.Run finished with code 0
TestMain: teardown ran, directory removed
ok example.test/lab
--- a failing test, to see the exit path
TestMain: setup (short mode: false )
--- FAIL: TestBroken
FAIL
TestMain: m.Run finished with code 1
FAIL example.test/lab
FAIL
--- the two ways to end TestMain
returning instead of calling os.Exit, with a failing test:
TestMain: setup (short mode: false )
--- FAIL: TestBroken
FAIL
TestMain: m.Run finished with code 1
TestMain: teardown ran, directory removed
FAIL example.test/lab
FAILThe order of events. In the first block, the setup line comes first, then three tests, then m.Run finished with code 0, then the teardown line. Everything the package does is bracketed by the single setup and teardown. The three tests are in two files, and the output shows TestInAnotherFile from extra_test.go running before the tests from main_test.go: Go sorts test files by name and runs tests in the order they appear within the sorted files. Do not depend on that order. Use -shuffle=on to find tests that do.
Short mode. The setup line printed short mode: true, because the command had -short. testing.Short() reads a flag, and flags are not parsed until flag.Parse() runs. The testing package parses them before running tests, but TestMain runs earlier, so if your setup depends on -short (or any custom flag), call flag.Parse() yourself at the top. Without it, testing.Short() panics with a message that flags have not been parsed.
The os.Exit trap
For years the documented pattern ended TestMain with os.Exit(m.Run()), because without it the process exited with code 0 regardless of the result. Since Go 1.15, if TestMain returns, the testing package calls os.Exit with the code m.Run produced, so you no longer need to.
The difference matters for cleanup. os.Exit terminates the process immediately and does not run deferred functions. The second block of the output has a test that fails, and the TestMain ends with os.Exit(code) when the code is non-zero. The output shows the setup, the failure, m.Run finished with code 1, and then no teardown line. The deferred os.RemoveAll never ran, and the temporary directory was left on disk. That is precisely the situation where cleanup matters most: a failing run, perhaps leaving a container or a lock file behind.
The third block rewrites the end of TestMain to just return (and the failing test is still there). The output now includes teardown ran, directory removed after m.Run finished with code 1, and the package still fails with a non-zero exit status. The fix is to return from TestMain rather than call os.Exit, and use defer freely. If you must call os.Exit (for instance because the setup failed before any defer was registered), do the cleanup by hand first.
A common structure that avoids the problem entirely moves the work into a function that can use defer:
func TestMain(m *testing.M) {
os.Exit(run(m)) // os.Exit sees the code run returned; run's defers have already executed
}
func run(m *testing.M) int {
cleanup := setup()
defer cleanup()
return m.Run()
}What belongs in TestMain
Good candidates are setups that are expensive and safely shared:
- starting a database or other service container once, with each test using its own schema or key prefix (integration tests, Testcontainers);
- building a binary for end-to-end tests of a command-line program (testing a CLI);
- loading a large read-only fixture;
- adjusting global state once for the whole package (and restoring it), such as the log level or a test-only random seed;
- checking for goroutine leaks at the end of the package (
goleak.VerifyTestMain, finding leaks).
What does not
Per-test state. If each test needs a fresh temp directory or server, create it in a helper with t.TempDir() or t.Cleanup, not in TestMain. Shared mutable state makes tests depend on each other and blocks parallel execution.
Setup that could be lazy. Not every test in the package needs the database. If only a few do, initialise it on first use with sync.Once in a helper, so go test -run TestPure does not pay for it. The helper can register the teardown with TestMain or leave the container to a reaper process.
Anything requiring *testing.T. TestMain has no t, so you cannot use t.Helper, t.TempDir or t.Fatal there. Failures in setup are reported with fmt.Println and os.Exit(1), which is less pleasant. Another reason to prefer helpers when cheap.
Skipping on conditions. To skip a whole package (a missing tool, a flag), TestMain can print a reason and os.Exit(0). Consider t.Skip in the individual tests as friendlier output.
Pitfalls
One per package. A second declaration is a compile error. When two files each want a TestMain, merge them or move the setup into helpers.
Packages are separate processes. TestMain runs once per package test binary. go test ./... runs packages in parallel (-p), so each package's setup runs concurrently: ports and database names must not collide, and a shared external resource needs a unique namespace per package.
Slow setup hides in every run. Setup in TestMain runs even for go test -run TestOne, because the test binary always starts it. If it takes five seconds, every single-test run does.
Panics in TestMain crash the binary with a stack trace and no test results. Keep setup code simple, and report errors before running tests.
Package-level init is not a substitute. init runs before flag.Parse, has no way to run teardown and runs for production code too if it lives in a non-test file.
The reference for the hook is the testing package documentation on TestMain, and the behaviour change in Go 1.15 is in its release notes.


