Test os Exit Paths and Main Functions in Go Programs
Test code that calls os.Exit in Go by re-running the test binary as a child process and checking its exit code and output, or by removing the exit call.

os.Exit terminates the process immediately, with no deferred functions and no return. In a test that means the entire test binary stops, and go test reports a failure without telling you which assertion you were on. So code that calls os.Exit or log.Fatal deep inside a helper cannot be tested by calling it.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
There are two answers. The cleaner one is to stop calling os.Exit there. Return an error, and let main turn it into a status, as shown in testing command line programs. Code you control should usually go that way.
When the exit is already there, in a library you do not own or a function that is too entangled to change today, run it in a child process.
The subprocess pattern
package main
import (
"fmt"
"os"
)
func mustPositive(n int) int {
if n <= 0 {
fmt.Fprintln(os.Stderr, "fatal: n must be positive")
os.Exit(3)
}
return n
}
func main() { fmt.Println(mustPositive(len(os.Args) - 1)) }mustPositive prints to standard error and exits with status 3 when its argument is not positive. This is the test:
package main
import (
"errors"
"os"
"os/exec"
"strings"
"testing"
)
func TestMustPositiveExits(t *testing.T) {
if os.Getenv("BE_CHILD") == "1" {
mustPositive(0) // never returns
return
}
cmd := exec.Command(os.Args[0], "-test.run=^TestMustPositiveExits$")
cmd.Env = append(os.Environ(), "BE_CHILD=1")
out, err := cmd.CombinedOutput()
var ee *exec.ExitError
if !errors.As(err, &ee) {
t.Fatalf("want an exit error, got %v", err)
}
if ee.ExitCode() != 3 {
t.Errorf("exit code = %d, want 3", ee.ExitCode())
}
if !strings.Contains(string(out), "n must be positive") {
t.Errorf("output = %q", out)
}
t.Logf("child exited with %d: %s", ee.ExitCode(), strings.TrimSpace(string(out)))
}
func TestMustPositiveOK(t *testing.T) {
if got := mustPositive(4); got != 4 {
t.Fatalf("got %d", got)
}
}go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #' child exited with 3: fatal: n must be positive
--- PASS: TestMustPositiveExits
--- PASS: TestMustPositiveOKThe test runs twice, in two roles. In the parent role, BE_CHILD is unset: it starts os.Args[0], which is the compiled test binary itself, with -test.run=^TestMustPositiveExits$ so that only this test executes, and sets BE_CHILD=1 in the child's environment. In the child role the same test sees the variable, calls mustPositive(0) and the process ends with status 3 before the next line.
The parent then checks the result. A non-zero exit appears as a *exec.ExitError, so errors.As extracts it and ExitCode() returns the status. A zero exit would give a nil error, which the test treats as a failure because it expected an exit. The output captured with CombinedOutput contains the message the function printed, and the parent asserts on it. The logged line shows the child exited with 3 and printed the expected text.
Three details prevent trouble. The return after the call is for the compiler and the reader; it is never reached. The -test.run pattern is anchored, so a similarly named test does not also run in the child. And the child inherits the parent's environment plus the flag, so if your code reads other environment variables they carry over, which is usually what you want.
Costs and alternatives
A subprocess test is slower than a function call, and coverage from the child is not merged into the parent's profile without extra tooling. Keep the number of such tests small, put the cases in a table where the child can take its input from an environment variable, and test the logic around them normally: the second test calls mustPositive(4) in-process and checks the happy path.
For log.Fatal, the same pattern works, since it calls os.Exit(1) after printing. For a whole main, the pattern can run main in the child with os.Args set, but if you can, move the logic into run and test that, as the CLI article does.
Go 1.20 added a related feature: building the program with -cover and running it, so integration tests of a binary can still collect coverage through GOCOVERDIR. See the Go coverage for integration tests page if the subprocess is a real program rather than a test helper. The standard library's own os/exec tests re-run the test binary as a helper process in the same way. The primary reference for the pieces is os/exec and os.Exit.


