skip to content

In TestMain, what does the int returned by m.Run mean, and how must it reach go test?

level: middleimportance: should knowfreq 42%

answer

  1. it is a process exit status
  2. zero means everything selected passed
  3. go test reads the binary's exit code
  4. os.Exit(0) after m.Run is a permanent pass
  5. capture the code, tear down, then exit with it

basics

~20 s

m.Run returns the test binary's exit code: zero if every selected test passed, non-zero otherwise. TestMain either passes it to os.Exit or simply returns, and the generated test main exits with it. Hardcoding os.Exit(0) hides real failures.

solid answer

~50 s

`m.Run()` runs the selected tests and returns the status the process should exit with - zero when everything passed, non-zero when anything failed, panicked or the run was aborted. That number is how the test binary talks to `go test`, which reports the package as FAIL when the binary exits non-zero. Two shapes are correct: the long-standing `os.Exit(m.Run())`, or capturing `code := m.Run()`, doing teardown, and then `os.Exit(code)`; simply returning from TestMain also works, because the generated test main exits with the code `m.Run` recorded. What is never correct is inventing your own status - `m.Run(); os.Exit(0)` throws the verdict away and gives you a permanently green package. The same value is your abort channel during setup: if the shared listener will not bind, print the error to stderr and `os.Exit(1)` before calling `m.Run` at all.

code

go · 6 lines
go
func TestMain(m *testing.M) {
	startFixture()
	m.Run() // return value dropped
	stopFixture()
	os.Exit(0) // always green, whatever the tests said
}

go deeper

for a junior

Remember that m.Run hands back a number that means pass or fail, and that it must be the number the process exits with. Never write os.Exit(0) after it.

for a middle

Explain that go test decides the package verdict from the binary's exit status, and show the capture-teardown-exit ordering plus the os.Exit(1) abort path when shared setup fails.

for a senior

Talk about detecting a disarmed package: deliberately break an assertion and confirm FAIL, and treat any hand-written TestMain as something to audit for a swallowed verdict.

for a principal

Own the guarantee that a green pipeline means something. Decide whether infrastructure failure gets its own non-zero code, and how a suite that stops reporting failures would be noticed at all.

## The contract `func (m *testing.M) Run() int` does the work the framework would have done without TestMain: it parses flags if they are not parsed yet, selects tests, benchmarks, examples and fuzz targets according to `-run`, `-bench`, `-fuzz` and friends, runs them, prints their output, and returns **the exit status the process should report**. - **0** - everything selected passed (or was skipped). - **non-zero** - at least one selected test failed, or the run was aborted. That integer is the only verdict channel the test binary has. `go test` launches the binary and marks the package FAIL when it exits non-zero. A TestMain that loses the value has broken the reporting chain, and it breaks it in the worst possible direction: towards green. ## Three shapes, two of them correct ```go // Correct, and the most explicit. func TestMain(m *testing.M) { os.Exit(m.Run()) } // Correct, and the shape you need when teardown exists. func TestMain(m *testing.M) { setup() code := m.Run() teardown() os.Exit(code) } // Broken: the package can never fail. func TestMain(m *testing.M) { setup() m.Run() teardown() os.Exit(0) } ``` The third one is not hypothetical - it is what people write when they add teardown to the first shape and reach for a literal exit code to keep the compiler quiet about an unused value. There is a fourth shape that is also correct in supported Go releases: TestMain may just **return** without calling `os.Exit` at all, and the generated test main exits with the status `m.Run` recorded. That is convenient, but writing the exit explicitly is still the clearer signal of intent, and it is what most codebases contain. ## Not calling m.Run at all The extreme version of the same failure. If TestMain returns without ever calling `m.Run`, no test in the package runs, nothing fails, and the binary exits zero. `go test` prints `ok` for the package with a suspiciously short duration. Nothing in the toolchain flags it. The only real defence is noticing that the package's output no longer lists any test names under `-v`, or that its coverage collapsed. ## Using the code as an abort channel Setup in TestMain runs before any test exists, so there is no `*testing.T` to fail. The idiom is to report on standard error and exit non-zero yourself: ```go lis, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { fmt.Fprintln(os.Stderr, "listen:", err) os.Exit(1) } ``` Exiting 1 from failed setup is right: the package genuinely did not pass. Exiting 0 because "the tests never got a chance to fail" is how an unbuildable fixture turns into a green build. Some suites prefer a distinct non-zero code (say 2) for infrastructure failure so a CI job can tell "tests failed" from "the environment is broken" - anything non-zero is a failure to `go test`, and the distinction is only for your own tooling. ## Why teams get bitten The symptom is always the same and always late: a package that has not reported a failure in months, discovered when someone deliberately breaks a test and the build stays green. The check is fast - break an assertion on purpose, run the package, and confirm `go test` prints FAIL and `echo $?` is non-zero. Any package with a hand-written TestMain deserves that check once. ## Interaction with the rest of the run - The exit status covers the **whole package**, not individual tests; per-test results are in the output stream, not the code. - Teardown that runs after `m.Run` cannot change the verdict. If cleanup itself fails and you care, you must fold that into the code you exit with - for example, exit non-zero when the shared fixture could not be released. - Because `os.Exit` skips deferred functions, the placement of teardown relative to `m.Run` and `os.Exit` is not a style choice; it is the difference between cleanup running and not running.

  • Setup in TestMain fails before any test runs. How do you report that?
    There is no `*testing.T` yet, so write the reason to `os.Stderr` and `os.Exit` with a non-zero status without calling `m.Run`. The package then fails, which is honest - it did not test anything. Exiting zero on failed setup turns a broken fixture into a green build.
  • If teardown after m.Run fails, should the package fail?
    Usually yes, if the failure means the environment is now dirty - a listener or child process left alive will break the next run. Fold it into the status you exit with: keep m.Run's code, and replace a zero with a non-zero one when cleanup failed. Never let cleanup downgrade a real test failure to success.
  • Does a non-zero exit code tell go test which test failed?
    No. The status is a single package-level verdict; the per-test detail lives in the binary's output, which `go test` streams and summarises. That is why a TestMain that swallows output or exits early can leave you with a FAIL and no visible reason.

saying these in an interview costs you the question

  • Calls m.Run then os.Exit(0) unconditionally
  • Thinks go test parses output rather than the exit status
  • Believes an unused m.Run result is a compile error
  • Exits zero when shared setup failed
  • Lets teardown overwrite a real test failure with success