Skip to main content

Testing CLI Applications in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

CLI applications are harder to test than libraries because they mix argument parsing, business logic, and I/O. The key to testable CLIs is separation: parse arguments into a clean input struct, run logic with injected dependencies, write output to an io.Writer you control. This makes every layer independently testable.

This guide covers the full spectrum — unit testing command handlers in isolation, capturing output, golden file tests for complex output, and end-to-end tests that run the compiled binary.

For Cobra CLI fundamentals see Go building CLI with Cobra. For test fundamentals see Go testing basics.

The Problem: Testable CLI Design

The most common mistake is writing commands that call os.Exit, print directly to os.Stdout, and embed all logic in the command handler. None of that can be tested without running the binary.

The fix: pass io.Writer for output, accept dependencies via interfaces, and return errors instead of calling os.Exit:

// ❌ Hard to test: direct os.Stdout, os.Exit, embedded logic
var rootCmd = &cobra.Command{
    RunE: func(cmd *cobra.Command, args []string) error {
        users, err := http.Get("https://api.example.com/users")
        if err != nil {
            fmt.Println("error:", err)
            os.Exit(1)
        }
        fmt.Println(users)
        return nil
    },
}

// ✅ Testable: injectable writer, injectable service, returns error
type ListUsersCmd struct {
    out     io.Writer
    service UserService
}

func (c *ListUsersCmd) Run(cmd *cobra.Command, args []string) error {
    users, err := c.service.List(cmd.Context())
    if err != nil {
        return fmt.Errorf("list users: %w", err)
    }
    for _, u := range users {
        fmt.Fprintf(c.out, "%s\t%s\n", u.ID, u.Name)
    }
    return nil
}

Testing a Command Handler in Isolation

With injectable dependencies, each command can be tested without a real network or filesystem:

// Define a minimal interface for what the command needs
type UserService interface {
    List(ctx context.Context) ([]User, error)
    Get(ctx context.Context, id string) (*User, error)
    Delete(ctx context.Context, id string) error
}

// Fake implementation for tests
type fakeUserService struct {
    users []User
    err   error
}

func (f *fakeUserService) List(_ context.Context) ([]User, error) {
    return f.users, f.err
}
func (f *fakeUserService) Get(_ context.Context, id string) (*User, error) {
    for _, u := range f.users {
        if u.ID == id { return &u, nil }
    }
    return nil, fmt.Errorf("not found")
}
func (f *fakeUserService) Delete(_ context.Context, id string) error { return f.err }

func TestListUsers(t *testing.T) {
    tests := []struct {
        name       string
        users      []User
        serviceErr error
        wantOutput string
        wantErr    bool
    }{
        {
            name:       "lists two users",
            users:      []User{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Bob"}},
            wantOutput: "u1\tAlice\nu2\tBob\n",
        },
        {
            name:       "empty list",
            users:      nil,
            wantOutput: "",
        },
        {
            name:       "service error",
            serviceErr: errors.New("connection refused"),
            wantErr:    true,
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            var buf bytes.Buffer
            cmd := &ListUsersCmd{
                out:     &buf,
                service: &fakeUserService{users: tc.users, err: tc.serviceErr},
            }

            cobraCmd := &cobra.Command{}
            err := cmd.Run(cobraCmd, nil)

            if tc.wantErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got := buf.String(); got != tc.wantOutput {
                t.Errorf("output = %q; want %q", got, tc.wantOutput)
            }
        })
    }
}

Capturing cobra.Command Output

Cobra routes command output through cmd.OutOrStdout() and cmd.ErrOrStderr(). Use cmd.SetOut and cmd.SetErr in tests to capture it:

func executeCommand(root *cobra.Command, args ...string) (stdout, stderr string, err error) {
    outBuf := new(bytes.Buffer)
    errBuf := new(bytes.Buffer)
    root.SetOut(outBuf)
    root.SetErr(errBuf)
    root.SetArgs(args)

    _, err = root.ExecuteC()
    return outBuf.String(), errBuf.String(), err
}

func TestRootCommand(t *testing.T) {
    cmd := NewRootCommand() // your command tree constructor

    t.Run("help flag", func(t *testing.T) {
        stdout, _, err := executeCommand(cmd, "--help")
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if !strings.Contains(stdout, "Usage:") {
            t.Errorf("help output missing Usage section, got: %s", stdout)
        }
    })

    t.Run("unknown flag returns error", func(t *testing.T) {
        _, stderr, err := executeCommand(cmd, "--definitely-not-a-flag")
        if err == nil {
            t.Error("expected error for unknown flag")
        }
        if !strings.Contains(stderr, "unknown flag") {
            t.Errorf("expected 'unknown flag' in stderr, got: %s", stderr)
        }
    })
}

Golden File Tests: Verifying Complex Output

When a command produces multi-line output (tables, formatted reports, JSON), comparing to a hardcoded string in the test is brittle. Golden files store the expected output in .golden files — update them with a flag when the output intentionally changes:

var update = flag.Bool("update", false, "update golden files")

func TestListUsersTable(t *testing.T) {
    svc := &fakeUserService{users: []User{
        {ID: "u1", Name: "Alice", Role: "admin"},
        {ID: "u2", Name: "Bob",   Role: "user"},
    }}

    var buf bytes.Buffer
    cmd := &ListUsersCmd{out: &buf, service: svc}
    cmd.Run(&cobra.Command{}, nil)

    goldenFile := filepath.Join("testdata", t.Name()+".golden")

    if *update {
        os.MkdirAll("testdata", 0755)
        os.WriteFile(goldenFile, buf.Bytes(), 0644)
        t.Logf("updated %s", goldenFile)
        return
    }

    expected, err := os.ReadFile(goldenFile)
    if err != nil {
        t.Fatalf("read golden file: %v (run with -update to create)", err)
    }
    if got := buf.String(); got != string(expected) {
        t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, expected)
    }
}

Run go test -update to regenerate all golden files after an intentional output change. Commit the .golden files alongside the test.

Testing with a Temporary Filesystem

Commands that read or write files need a temporary directory:

func TestExportCommand(t *testing.T) {
    // t.TempDir() is cleaned up automatically after the test
    dir := t.TempDir()
    outFile := filepath.Join(dir, "export.json")

    var buf bytes.Buffer
    cmd := &ExportCmd{out: &buf, outputPath: outFile}
    if err := cmd.Run(&cobra.Command{}, nil); err != nil {
        t.Fatalf("run: %v", err)
    }

    data, err := os.ReadFile(outFile)
    if err != nil {
        t.Fatalf("read output: %v", err)
    }

    var result ExportResult
    if err := json.Unmarshal(data, &result); err != nil {
        t.Fatalf("parse output: %v", err)
    }
    if len(result.Users) == 0 {
        t.Error("expected users in export")
    }
}

t.TempDir() (Go 1.15+) is preferable to os.MkdirTemp + deferred removal — it’s automatically cleaned up even if the test panics.

End-to-End Tests: Running the Compiled Binary

For confidence that the full binary works correctly, compile it and run it with exec.Command. These are slower and require a build step, but they test the actual user experience:

func TestBinaryE2E(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping e2e test in short mode")
    }

    // Build the binary into a temp dir
    dir := t.TempDir()
    binary := filepath.Join(dir, "myapp")
    if err := exec.Command("go", "build", "-o", binary, ".").Run(); err != nil {
        t.Fatalf("build: %v", err)
    }

    tests := []struct {
        name       string
        args       []string
        wantStatus int
        wantOutput string
        wantErr    string
    }{
        {
            name:       "version flag",
            args:       []string{"--version"},
            wantStatus: 0,
            wantOutput: "myapp v",
        },
        {
            name:       "unknown command",
            args:       []string{"nonexistent"},
            wantStatus: 1,
            wantErr:    "unknown command",
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
            defer cancel()

            cmd := exec.CommandContext(ctx, binary, tc.args...)
            stdout, stderr, exitCode := runCmd(cmd)

            if exitCode != tc.wantStatus {
                t.Errorf("exit code = %d; want %d\nstdout: %s\nstderr: %s",
                    exitCode, tc.wantStatus, stdout, stderr)
            }
            if tc.wantOutput != "" && !strings.Contains(stdout, tc.wantOutput) {
                t.Errorf("stdout %q does not contain %q", stdout, tc.wantOutput)
            }
            if tc.wantErr != "" && !strings.Contains(stderr, tc.wantErr) {
                t.Errorf("stderr %q does not contain %q", stderr, tc.wantErr)
            }
        })
    }
}

func runCmd(cmd *exec.Cmd) (stdout, stderr string, exitCode int) {
    var outBuf, errBuf bytes.Buffer
    cmd.Stdout = &outBuf
    cmd.Stderr = &errBuf
    err := cmd.Run()
    exitCode = 0
    if exitErr, ok := err.(*exec.ExitError); ok {
        exitCode = exitErr.ExitCode()
    }
    return outBuf.String(), errBuf.String(), exitCode
}

Use testing.Short() to skip e2e tests during fast iteration — run them with -run TestBinaryE2E or in CI.

Testing Interactive Prompts

Interactive prompts (password input, confirmations, menus) are tested by piping input through cmd.Stdin:

func TestConfirmationPrompt(t *testing.T) {
    tests := []struct {
        input   string
        wantYes bool
    }{
        {"y\n", true},
        {"yes\n", true},
        {"n\n", false},
        {"no\n", false},
        {"\n", false},  // empty = default no
    }

    for _, tc := range tests {
        t.Run(tc.input, func(t *testing.T) {
            var outBuf bytes.Buffer
            p := NewPrompter(strings.NewReader(tc.input), &outBuf)
            got, err := p.Confirm("Delete all records?")
            if err != nil {
                t.Fatalf("confirm: %v", err)
            }
            if got != tc.wantYes {
                t.Errorf("Confirm() = %v; want %v (input=%q)", got, tc.wantYes, tc.input)
            }
        })
    }
}

This requires the Prompter to accept an io.Reader for input — another reason to inject I/O rather than reading from os.Stdin directly.

Summary

  • Design commands with injectable io.Writer output and interface-based dependencies — this is the prerequisite for testability
  • Use cmd.SetOut / cmd.SetErr to capture Cobra command output in tests
  • Use golden files for complex, multi-line output — update with -update flag when output changes intentionally
  • Use t.TempDir() for file I/O tests — automatically cleaned up, even on panic
  • Run e2e tests by building the binary in t.TempDir() and running it with exec.CommandContext
  • Gate e2e tests with testing.Short() — run them in CI but skip during fast development iteration

Resources

Comments

👍 Was this article helpful?