Testing CLI Tools

Updated

September 8, 2026

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

  1. Refactor any sample CLI to realMain and add three table cases.
  2. Add a usage test that expects code 2 and stderr containing "usage".
  3. Golden-test JSON output with UPDATE_GOLDEN=1 go test.