Skip to main content
โšก Calmops

Testing CLI Applications in Go

Testing CLI Applications in Go

Introduction

Testing CLI applications requires different strategies than testing libraries. This guide covers unit testing, integration testing, and end-to-end testing for CLI tools.

Comprehensive testing ensures your CLI applications work reliably across different scenarios and platforms.

Unit Testing CLI Components

Testing Command Handlers

package main

import (
	"bytes"
	"testing"
)

// TestCreateCommand tests the create command
func TestCreateCommand(t *testing.T) {
	tests := []struct {
		name    string
		args    []string
		wantErr bool
	}{
		{
			name:    "valid arguments",
			args:    []string{"create", "--name", "test"},
			wantErr: false,
		},
		{
			name:    "missing required flag",
			args:    []string{"create"},
			wantErr: true,
		},
		{
			name:    "invalid flag",
			args:    []string{"create", "--invalid"},
			wantErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Test implementation
		})
	}
}

// TestListCommand tests the list command
func TestListCommand(t *testing.T) {
	tests := []struct {
		name     string
		args     []string
		wantErr  bool
		contains string
	}{
		{
			name:     "list all",
			args:     []string{"list"},
			wantErr:  false,
			contains: "item1",
		},
		{
			name:     "list with filter",
			args:     []string{"list", "--filter", "active"},
			wantErr:  false,
			contains: "active",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Test implementation
		})
	}
}

Good: Proper CLI Testing Implementation

package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"
)

// CLITestCase represents a CLI test case
type CLITestCase struct {
	Name        string
	Args        []string
	Input       string
	WantExitCode int
	WantOutput  string
	WantError   string
	Timeout     time.Duration
}

// CLITester handles CLI testing
type CLITester struct {
	binaryPath string
}

// NewCLITester creates a new CLI tester
func NewCLITester(binaryPath string) *CLITester {
	return &CLITester{
		binaryPath: binaryPath,
	}
}

// Run runs a CLI test case
func (ct *CLITester) Run(tc CLITestCase) (string, int, error) {
	ctx := context.Background()
	if tc.Timeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, tc.Timeout)
		defer cancel()
	}

	cmd := exec.CommandContext(ctx, ct.binaryPath, tc.Args...)

	// Set input
	if tc.Input != "" {
		cmd.Stdin = strings.NewReader(tc.Input)
	}

	// Capture output
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	// Run command
	err := cmd.Run()

	output := stdout.String()
	if stderr.Len() > 0 {
		output += stderr.String()
	}

	exitCode := 0
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			exitCode = exitErr.ExitCode()
		}
	}

	return output, exitCode, err
}

// TestCase runs a test case
func (ct *CLITester) TestCase(t *testing.T, tc CLITestCase) {
	output, exitCode, err := ct.Run(tc)

	// Check exit code
	if exitCode != tc.WantExitCode {
		t.Errorf("exit code: got %d, want %d", exitCode, tc.WantExitCode)
	}

	// Check output
	if tc.WantOutput != "" && !strings.Contains(output, tc.WantOutput) {
		t.Errorf("output: got %q, want to contain %q", output, tc.WantOutput)
	}

	// Check error
	if tc.WantError != "" && !strings.Contains(output, tc.WantError) {
		t.Errorf("error: got %q, want to contain %q", output, tc.WantError)
	}
}

// TestCases runs multiple test cases
func (ct *CLITester) TestCases(t *testing.T, cases []CLITestCase) {
	for _, tc := range cases {
		t.Run(tc.Name, func(t *testing.T) {
			ct.TestCase(t, tc)
		})
	}
}

// Example test
func TestCLIApplication(t *testing.T) {
	tester := NewCLITester("./myapp")

	cases := []CLITestCase{
		{
			Name:         "help command",
			Args:         []string{"--help"},
			WantExitCode: 0,
			WantOutput:   "Usage:",
		},
		{
			Name:         "version command",
			Args:         []string{"--version"},
			WantExitCode: 0,
			WantOutput:   "version",
		},
		{
			Name:         "invalid command",
			Args:         []string{"invalid"},
			WantExitCode: 1,
			WantError:    "unknown command",
		},
	}

	tester.TestCases(t, cases)
}

Bad: Improper CLI Testing

package main

import (
	"testing"
)

// BAD: No test cases
func TestBadCLI(t *testing.T) {
	// No actual testing
}

// BAD: No error checking
func TestBadCommand(t *testing.T) {
	// No assertions
	// No output verification
}

// BAD: No timeout
func TestBadTimeout(t *testing.T) {
	// Could hang indefinitely
}

Problems:

  • No test cases
  • No assertions
  • No error checking
  • No timeout management

Integration Testing

Testing with Fixtures

package main

import (
	"io/ioutil"
	"os"
	"path/filepath"
	"testing"
)

// Fixture manages test fixtures
type Fixture struct {
	TempDir string
}

// NewFixture creates a new fixture
func NewFixture(t *testing.T) *Fixture {
	tempDir, err := ioutil.TempDir("", "cli-test-")
	if err != nil {
		t.Fatal(err)
	}

	return &Fixture{
		TempDir: tempDir,
	}
}

// Cleanup cleans up the fixture
func (f *Fixture) Cleanup() error {
	return os.RemoveAll(f.TempDir)
}

// CreateFile creates a test file
func (f *Fixture) CreateFile(name, content string) error {
	path := filepath.Join(f.TempDir, name)
	return ioutil.WriteFile(path, []byte(content), 0644)
}

// ReadFile reads a test file
func (f *Fixture) ReadFile(name string) (string, error) {
	path := filepath.Join(f.TempDir, name)
	data, err := ioutil.ReadFile(path)
	return string(data), err
}

// TestWithFixture tests with fixtures
func TestWithFixture(t *testing.T) {
	fixture := NewFixture(t)
	defer fixture.Cleanup()

	// Create test files
	fixture.CreateFile("input.txt", "test data")

	// Run CLI command
	// Verify output
}

Testing with Mock Services

package main

import (
	"fmt"
	"testing"
)

// MockService mocks external service
type MockService struct {
	GetUserFunc func(id string) (string, error)
	CreateUserFunc func(name string) (string, error)
}

// GetUser mocks GetUser
func (ms *MockService) GetUser(id string) (string, error) {
	if ms.GetUserFunc != nil {
		return ms.GetUserFunc(id)
	}
	return "", fmt.Errorf("not implemented")
}

// CreateUser mocks CreateUser
func (ms *MockService) CreateUser(name string) (string, error) {
	if ms.CreateUserFunc != nil {
		return ms.CreateUserFunc(name)
	}
	return "", fmt.Errorf("not implemented")
}

// TestWithMock tests with mock service
func TestWithMock(t *testing.T) {
	mock := &MockService{
		GetUserFunc: func(id string) (string, error) {
			return "John Doe", nil
		},
	}

	user, err := mock.GetUser("123")
	if err != nil {
		t.Fatal(err)
	}

	if user != "John Doe" {
		t.Errorf("got %q, want %q", user, "John Doe")
	}
}

End-to-End Testing

Scenario Testing

package main

import (
	"testing"
)

// Scenario represents an end-to-end scenario
type Scenario struct {
	Name  string
	Steps []Step
}

// Step represents a test step
type Step struct {
	Description string
	Action      func() error
	Verify      func() error
}

// RunScenario runs a scenario
func RunScenario(t *testing.T, scenario Scenario) {
	for _, step := range scenario.Steps {
		t.Run(step.Description, func(t *testing.T) {
			if err := step.Action(); err != nil {
				t.Fatalf("action failed: %v", err)
			}

			if err := step.Verify(); err != nil {
				t.Fatalf("verification failed: %v", err)
			}
		})
	}
}

// Example scenario
func TestUserManagementScenario(t *testing.T) {
	scenario := Scenario{
		Name: "User Management",
		Steps: []Step{
			{
				Description: "Create user",
				Action: func() error {
					// Create user
					return nil
				},
				Verify: func() error {
					// Verify user created
					return nil
				},
			},
			{
				Description: "List users",
				Action: func() error {
					// List users
					return nil
				},
				Verify: func() error {
					// Verify user in list
					return nil
				},
			},
			{
				Description: "Delete user",
				Action: func() error {
					// Delete user
					return nil
				},
				Verify: func() error {
					// Verify user deleted
					return nil
				},
			},
		},
	}

	RunScenario(t, scenario)
}

Best Practices

1. Test Error Cases

{
	Name:         "invalid input",
	Args:         []string{"--invalid"},
	WantExitCode: 1,
	WantError:    "invalid",
}

2. Test Output Format

{
	Name:        "json output",
	Args:        []string{"--format", "json"},
	WantOutput:  "{",
}

3. Test Timeouts

{
	Name:    "long operation",
	Timeout: 5 * time.Second,
}

4. Test Edge Cases

{
	Name: "empty input",
	Args: []string{""},
}

Common Pitfalls

1. No Error Testing

Always test error cases.

2. No Output Verification

Verify output format and content.

3. No Timeout Testing

Test timeout behavior.

4. No Cleanup

Always clean up test artifacts.

Resources

Summary

Comprehensive testing ensures CLI reliability. Key takeaways:

  • Test command handlers
  • Test error cases
  • Verify output format
  • Use fixtures for integration tests
  • Use mocks for external services
  • Test end-to-end scenarios
  • Always clean up test artifacts

By mastering CLI testing, you can build reliable applications.

Comments