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.Writeroutput andinterface-based dependencies — this is the prerequisite for testability - Use
cmd.SetOut/cmd.SetErrto capture Cobra command output in tests - Use golden files for complex, multi-line output — update with
-updateflag 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 withexec.CommandContext - Gate e2e tests with
testing.Short()— run them in CI but skip during fast development iteration
Comments