Testing CLI Tools
Testing CLI Tools
Overview
Test CLIs as pure functions of args and streams. Avoid os.Exit and global flag.CommandLine in the unit under test. Use FlagSet with ContinueOnError, bytes.Buffer, and table tests.
Golden structure
// cmd/mytool/main.go
func main() {
os.Exit(realMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
func realMain(args []string, in io.Reader, out, errOut io.Writer) int {
if err := run(args, in, out, errOut); err != nil {
fmt.Fprintln(errOut, "mytool:", err)
if errors.Is(err, errUsage) {
return 2
}
return 1
}
return 0
}func TestRunHello(t *testing.T) {
var out bytes.Buffer
code := realMain([]string{"-name", "Ada"}, strings.NewReader(""), &out, io.Discard)
if code != 0 {
t.Fatalf("code=%d", code)
}
if got := out.String(); got != "hello, Ada\n" {
t.Fatalf("got %q", got)
}
}FlagSet in run()
func run(args []string, in io.Reader, out, errOut io.Writer) error {
fs := flag.NewFlagSet("mytool", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "world", "name")
if err := fs.Parse(args); err != nil {
return errUsage
}
_, err := fmt.Fprintf(out, "hello, %s\n", *name)
return err
}Never call flag.Parse() on the global set inside library tests—it races and mutates process state.
Table-driven cases
func TestCLI(t *testing.T) {
tests := []struct {
name string
args []string
in string
code int
want string
}{
{"ok", []string{"-name", "Zo"}, "", 0, "hello, Zo\n"},
{"default", nil, "", 0, "hello, world\n"},
{"badflag", []string{"-nope"}, "", 2, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out, errB bytes.Buffer
code := realMain(tt.args, strings.NewReader(tt.in), &out, &errB)
if code != tt.code {
t.Fatalf("code=%d err=%s", code, errB.String())
}
if tt.want != "" && out.String() != tt.want {
t.Fatalf("out=%q want %q", out.String(), tt.want)
}
})
}
}Temp files and env
func TestWithConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "c.json")
if err := os.WriteFile(path, []byte(`{"verbose":true}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MYTOOL_API_URL", "http://example")
// call run with --config path
}Subprocess tests (optional)
When you must test main packaging:
func TestMainBinary(t *testing.T) {
if os.Getenv("BE_CRASHER") == "1" {
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainBinary")
cmd.Env = append(os.Environ(), "BE_CRASHER=1")
err := cmd.Run()
// assert exit code via ExitError
}Prefer in-process tests; subprocess tests are slower and messier.
Golden files
func TestOutputGolden(t *testing.T) {
var out bytes.Buffer
_ = run([]string{"list"}, nil, &out, io.Discard)
golden := filepath.Join("testdata", "list.golden")
if os.Getenv("UPDATE_GOLDEN") != "" {
_ = os.WriteFile(golden, out.Bytes(), 0o644)
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(out.Bytes(), want) {
t.Fatalf("mismatch")
}
}Race and parallel
func TestX(t *testing.T) {
t.Parallel()
// only if run uses no process-global state
}Rules of thumb
| Do | Don’t |
|---|---|
| Inject Reader/Writer | Read/write only os.Stdout in core logic |
Return exit codes from realMain |
os.Exit inside tests |
| Private FlagSet | Global flag.Parse in packages |
t.Setenv / t.TempDir |
Touch real $HOME config |
Try next
- Refactor any sample CLI to
realMainand add three table cases. - Add a usage test that expects code 2 and stderr containing
"usage". - Golden-test JSON output with
UPDATE_GOLDEN=1 go test.