Skip to content
shellhacks.dev
Go

Test a Go CLI Program Using TestMain and exec.Command

Test a Go command line program by building it once in TestMain and running it with exec.Command in each test, checking output and exit codes with ExitError.

2 min read
Summarize in AI

A Go command line tool should be tested the way a user runs it, with real arguments and a real exit status.

Shell
export HOME=/tmp
mkdir cli && cd cli
go mod init cli 2> /dev/null
cat > main.go <<'EOF'
package main

import (
	"fmt"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: cli NAME")
		os.Exit(2)
	}
	fmt.Println("hello", os.Args[1])
}
EOF
cat > main_test.go <<'EOF'
package main

import (
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
)

var bin string

func TestMain(m *testing.M) {
	dir, _ := os.MkdirTemp("", "cli")
	bin = filepath.Join(dir, "cli")
	if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
		panic(string(out))
	}
	code := m.Run()
	os.RemoveAll(dir)
	os.Exit(code)
}

func TestGreets(t *testing.T) {
	out, err := exec.Command(bin, "ann").Output()
	if err != nil || string(out) != "hello ann\n" {
		t.Fatalf("got %q, %v", out, err)
	}
}

func TestUsageExitCode(t *testing.T) {
	err := exec.Command(bin).Run()
	var ee *exec.ExitError
	if !errors.As(err, &ee) || ee.ExitCode() != 2 {
		t.Fatalf("want exit 2, got %v", err)
	}
}
EOF
go test -count=1 -v .
Text
=== RUN   TestGreets
--- PASS: TestGreets (0.00s)
=== RUN   TestUsageExitCode
--- PASS: TestUsageExitCode (0.00s)
PASS
ok  	cli	0.101s

TestMain runs before the tests of a package, so it is the place to build the binary once with go build into a temporary directory and to remove it afterward. Each test then calls exec.Command(bin, args...); Output captures what the tool printed, and errors.As with *exec.ExitError gives access to ExitCode() for the failure case. Both tests passed, one for the greeting and one for exit status 2 without arguments. See TestMain in the testing package.

Building takes a moment, which is why it happens once and not per test. Since Go 1.15, returning from TestMain exits with the status of m.Run, but the explicit os.Exit shown here is needed to run cleanup code after it. Tests that only call functions are faster, so keep the exec tests for the behavior of the whole program. This was run with Go 1.19.

Taggedgotesting
Share

More snippets

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.