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