Testing Go CLI Applications with Cobra and testscript
CLI tools are deceptively hard to test. They interact through stdin and stdout, depend on environment variables and working directories, return exit codes instead of values, and often spawn subprocesses. Most teams settle for a few manual smoke tests and call it done. When the CLI breaks in a way that only surfaces on a specific flag combination or with a specific environment variable set, the bug ships.
This guide covers the full testing stack for Go CLIs built with Cobra: unit testing individual commands, capturing output, integration testing with os/exec, and the testscript package that turns human-readable script files into hermetic integration tests.
Why CLI Testing is Different
Unit testing a library function is clean: pass inputs, check outputs, mock dependencies. CLI testing involves:
- stdin/stdout/stderr — output goes to file descriptors, not return values
- Exit codes — non-zero exit communicates errors, but panics also produce non-zero exits
- Environment variables —
$HOME,$PATH,$CONFIG_DIRaffect behavior globally - Working directory — relative paths resolve against
os.Getwd(), which changes across tests - Subprocesses — your CLI may call
git,docker, or other tools - Configuration files — many CLIs read config from
~/.config/tool/config.yaml
Testing any one of these in isolation is fine. Testing them together, in combinations, while keeping tests hermetic and parallel-safe, is the hard part.
Project Structure
A testable Cobra CLI has clean separation between the command definition and the business logic:
myapp/
cmd/
root.go # Root command setup
deploy.go # deploy subcommand
version.go # version subcommand
internal/
deployer/
deployer.go # Business logic, not cobra-specific
main.go
cmd/root_test.go
cmd/deploy_test.go
testdata/
scripts/ # testscript .txtar filesThe deployer package handles actual work. The cmd/ package handles CLI parsing and delegates to deployer. This means you can test the business logic independently of the CLI, and test the CLI independently of the actual deployment logic.
Unit Testing Cobra Commands
Cobra commands support redirecting output via SetOut, SetErr, and setting args via SetArgs. This lets you test command behavior without running a subprocess:
// cmd/version.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var Version = "dev"
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.OutOrStdout(), Version)
return nil
},
}
}The test:
// cmd/version_test.go
package cmd
import (
"bytes"
"testing"
)
func TestVersionCommand(t *testing.T) {
Version = "1.2.3" // Set for test
var buf bytes.Buffer
cmd := newVersionCmd()
cmd.SetOut(&buf)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := buf.String()
want := "1.2.3\n"
if got != want {
t.Errorf("version output: got %q, want %q", got, want)
}
}For commands with flags, set them via SetArgs:
func TestDeployCommand(t *testing.T) {
tests := []struct {
name string
args []string
wantOut string
wantErr bool
}{
{
name: "deploy to staging",
args: []string{"--env", "staging", "--version", "1.2.3"},
wantOut: "Deploying 1.2.3 to staging",
wantErr: false,
},
{
name: "fails with missing version",
args: []string{"--env", "staging"},
wantErr: true,
},
{
name: "fails with invalid env",
args: []string{"--env", "production_typo", "--version", "1.2.3"},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var outBuf, errBuf bytes.Buffer
// Create a fresh deployer mock
mockDeployer := &MockDeployer{}
cmd := newDeployCmd(mockDeployer)
cmd.SetOut(&outBuf)
cmd.SetErr(&errBuf)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if tc.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
if tc.wantOut != "" && !bytes.Contains(outBuf.Bytes(), []byte(tc.wantOut)) {
t.Errorf("output %q does not contain %q", outBuf.String(), tc.wantOut)
}
})
}
}Mocking Dependencies
Dependency injection makes Cobra commands testable. Instead of calling os.MkdirAll directly, accept an interface:
// internal/deployer/deployer.go
package deployer
type FileSystem interface {
MkdirAll(path string, perm os.FileMode) error
WriteFile(name string, data []byte, perm os.FileMode) error
ReadFile(name string) ([]byte, error)
}
type Deployer struct {
fs FileSystem
}
func New(fs FileSystem) *Deployer {
return &Deployer{fs: fs}
}The test uses a fake filesystem:
// internal/deployer/fake_fs_test.go
type fakeFS struct {
files map[string][]byte
dirs map[string]bool
mu sync.Mutex
}
func newFakeFS() *fakeFS {
return &fakeFS{
files: make(map[string][]byte),
dirs: make(map[string]bool),
}
}
func (f *fakeFS) MkdirAll(path string, perm os.FileMode) error {
f.mu.Lock()
defer f.mu.Unlock()
f.dirs[path] = true
return nil
}
func (f *fakeFS) WriteFile(name string, data []byte, perm os.FileMode) error {
f.mu.Lock()
defer f.mu.Unlock()
f.files[name] = data
return nil
}
func (f *fakeFS) ReadFile(name string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
data, ok := f.files[name]
if !ok {
return nil, os.ErrNotExist
}
return data, nil
}Integration Testing with os/exec
For true end-to-end testing, build the binary and run it as a subprocess:
// cmd/integration_test.go
package cmd_test
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"testing"
)
var binaryPath string
func TestMain(m *testing.M) {
// Build the binary once for all integration tests
tmpDir, err := os.MkdirTemp("", "myapp-test-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(tmpDir)
binaryPath = filepath.Join(tmpDir, "myapp")
build := exec.Command("go", "build", "-o", binaryPath, "./...")
build.Dir = "../" // repo root
if out, err := build.CombinedOutput(); err != nil {
panic(string(out))
}
os.Exit(m.Run())
}
func runCLI(t *testing.T, args ...string) (stdout, stderr string, exitCode int) {
t.Helper()
var outBuf, errBuf bytes.Buffer
cmd := exec.Command(binaryPath, args...)
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
exitCode = 0
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if err != nil {
t.Fatalf("unexpected error running CLI: %v", err)
}
return outBuf.String(), errBuf.String(), exitCode
}
func TestVersionIntegration(t *testing.T) {
stdout, _, exitCode := runCLI(t, "version")
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
if stdout == "" {
t.Error("expected version output, got empty string")
}
}
func TestHelpFlag(t *testing.T) {
stdout, _, exitCode := runCLI(t, "--help")
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
if !bytes.Contains([]byte(stdout), []byte("Usage:")) {
t.Errorf("help output does not contain 'Usage:', got: %s", stdout)
}
}testscript: The Best Way to Integration Test CLIs
The testscript package (from golang.org/x/tools/txtar and rsc.io/script/scripttest) provides a scripting language designed exactly for testing CLI tools. Tests are .txtar files containing script commands and file contents. They are hermetic (each runs in its own temp directory), readable (plain text), and maintainable (no test framework boilerplate).
Install:
go get github.com/rogpeppe/go-internal/testscript@latestA test script looks like this:
# testdata/scripts/deploy.txtar
# Setup: create a config file
mkdir -p config
cp config.yaml config/config.yaml
# Test: successful deploy to staging
exec myapp deploy --env staging --version 1.2.3
stdout 'Deploying 1.2.3 to staging'
! stderr .
# Test: fails with unknown environment
! exec myapp deploy --env unknown --version 1.2.3
stderr 'Unknown environment'
-- config/config.yaml --
endpoint: https://staging.example.com
timeout: 30sThe Go test that runs it:
// cmd/testscript_test.go
package cmd_test
import (
"os"
"testing"
"github.com/rogpeppe/go-internal/testscript"
)
func TestScript(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "testdata/scripts",
Cmds: map[string]func(*testscript.TestScript, bool, []string){
// Register 'myapp' as a command that runs our binary
},
Setup: func(env *testscript.Env) error {
// Make our binary available as 'myapp' in scripts
env.Setenv("PATH", binaryDir+":"+os.Getenv("PATH"))
return nil
},
})
}The testscript language has commands for:
exec cmd args— run a command! exec cmd args— run a command, expect failurestdout pattern— assert stdout matches regexstderr pattern— assert stderr matches regex! stdout pattern— assert stdout does NOT matchcmp file1 file2— compare file contentsexists file— assert file existsmkdir dir— create directorycp src dst— copy fileenv NAME=value— set environment variable
Golden File Testing
Golden file testing is the pattern of storing expected output in files and comparing against them. It is useful for commands that produce complex or multi-line output:
func TestDeployOutputGolden(t *testing.T) {
stdout, _, exitCode := runCLI(t, "deploy", "--env", "staging", "--version", "1.2.3")
if exitCode != 0 {
t.Fatalf("unexpected exit code: %d", exitCode)
}
goldenPath := filepath.Join("testdata", "golden", "deploy-staging.txt")
if os.Getenv("UPDATE_GOLDEN") == "1" {
// Update the golden file
err := os.WriteFile(goldenPath, []byte(stdout), 0644)
if err != nil {
t.Fatalf("writing golden file: %v", err)
}
t.Logf("Updated golden file: %s", goldenPath)
return
}
expected, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("reading golden file: %v", err)
}
if string(expected) != stdout {
t.Errorf("output mismatch:\nwant:\n%s\ngot:\n%s", expected, stdout)
}
}Update golden files with:
UPDATE_GOLDEN=1 go test ./cmd/... -run TestDeployOutputGoldenCommit the updated golden files. The diff in your PR shows exactly what changed in the output, making reviews straightforward.
Table-Driven Tests for Flag Combinations
Table-driven tests cover flag combinations efficiently:
func TestDeployFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantCode int
wantOut string
wantErr string
}{
{"staging deploy", []string{"deploy", "--env", "staging", "--version", "1.0.0"}, 0, "Deploying", ""},
{"production deploy", []string{"deploy", "--env", "production", "--version", "2.0.0"}, 0, "Deploying", ""},
{"missing env flag", []string{"deploy", "--version", "1.0.0"}, 1, "", "required flag"},
{"missing version flag", []string{"deploy", "--env", "staging"}, 1, "", "required flag"},
{"invalid env", []string{"deploy", "--env", "qa", "--version", "1.0.0"}, 2, "", "Unknown environment"},
{"invalid version format", []string{"deploy", "--env", "staging", "--version", "latest"}, 3, "", "Invalid version"},
{"dry run flag", []string{"deploy", "--env", "staging", "--version", "1.0.0", "--dry-run"}, 0, "DRY RUN", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, exitCode := runCLI(t, tc.args...)
if exitCode != tc.wantCode {
t.Errorf("exit code: got %d, want %d\nstdout: %s\nstderr: %s",
exitCode, tc.wantCode, stdout, stderr)
}
if tc.wantOut != "" && !bytes.Contains([]byte(stdout), []byte(tc.wantOut)) {
t.Errorf("stdout %q does not contain %q", stdout, tc.wantOut)
}
if tc.wantErr != "" && !bytes.Contains([]byte(stderr), []byte(tc.wantErr)) {
t.Errorf("stderr %q does not contain %q", stderr, tc.wantErr)
}
})
}
}CI Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- name: Run unit tests
run: go test ./... -race -count=1
- name: Run integration tests
run: go test ./cmd/... -run TestScript -v
- name: Run with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.outThe -race flag detects data races. The -count=1 flag prevents Go's test caching (important for integration tests that depend on external state).
A well-tested CLI is one where every documented flag combination has a test, every error path has a test, and go test ./... gives you confidence before every release. testscript in particular deserves wider adoption — its .txtar format makes test intent clear to everyone on the team, not just the Go developers.
HelpMeTest adds production monitoring and AI-powered test generation for Go services — start free at helpmetest.com