This document describes the testing philosophy, patterns, and best practices for A.R.C. CLI.
A.R.C. CLI follows a lenient testing approach that balances quality with development velocity:
- Focus on Functionality: Tests ensure code works correctly, not just coverage numbers
- Pragmatic Coverage: 60-70% overall coverage (75%+ for critical paths)
- Clear Intent: Test names describe what's being tested and why
- Maintainability: Use helpers and fixtures to reduce duplication
- Fast Execution: Unit tests complete in < 10 seconds
# Run all unit tests
make test
# Run with coverage report
make test-coverage
# Generate HTML coverage report
make test-coverage-html
# Run integration tests (slower)
make test-integration
# Run benchmarks
make test-benchpackage mypackage
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMyFunction(t *testing.T) {
result := MyFunction("input")
require.Equal(t, "expected", result)
}Tests live alongside the code they test:
pkg/state/
├── state.go
├── state_test.go # Unit tests for state.go
├── storage.go
├── storage_test.go # Unit tests for storage.go
├── integration_test.go # Integration tests (build tag)
└── testdata/
└── *.yaml # Test fixtures
Integration tests use build tags to separate slow tests from fast unit tests:
//go:build integration
package state
import "testing"
func TestFullStateLifecycle(t *testing.T) {
// Integration test using real file system
}Run integration tests separately:
go test -tags=integration ./...Test fixtures belong in testdata/ directories:
pkg/state/testdata/
├── valid_state.yaml
├── corrupted_state.yaml
└── empty_state.yaml
Use table-driven tests for multiple scenarios:
func TestStateValidation(t *testing.T) {
tests := []struct {
name string
state *State
wantErr bool
}{
{
name: "valid state",
state: &State{Version: 1},
wantErr: false,
},
{
name: "invalid version",
state: &State{Version: 0},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func (t *testing.T) {
err := tt.state.Validate()
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}Use testify suites for shared setup/teardown:
import "github.com/stretchr/testify/suite"
type StorageTestSuite struct {
suite.Suite
tempDir string
storage *Storage
}
func (s *StorageTestSuite) SetupTest() {
s.tempDir = s.T().TempDir()
s.storage = NewStorage(s.tempDir)
}
func (s *StorageTestSuite) TestReadState() {
// Test implementation
}
func TestStorageSuite(t *testing.T) {
suite.Run(t, new(StorageTestSuite))
}Integration tests use real dependencies:
//go:build integration
func TestStateStorageIntegration(t *testing.T) {
// Use real file system
dir := t.TempDir()
storage := NewStorage(dir)
// Test full workflow
state := ValidState()
err := storage.WriteState(state)
require.NoError(t, err)
loaded, err := storage.ReadState()
require.NoError(t, err)
require.Equal(t, state.Version, loaded.Version)
}A.R.C. provides shared test utilities in internal/testing/:
import arctest "github.com/arc-framework/arc-cli/internal/testing"
// Create temporary directory
dir := arctest.TempDir(t)
// Capture output
stdout, stderr := arctest.CaptureOutput(t, func () {
fmt.Println("hello")
})
// Create test files
path := arctest.CreateFile(t, "test.yaml", []byte("content"))
// Create state files
arctest.CreateStateFile(t, dir, validState)// Mock file system
mockFS := arctest.NewMockFS()
mockFS.WriteFile("state.yaml", []byte("data"), 0644)
// Mock clock
mockClock := arctest.NewMockClock(time.Now())
mockClock.Advance(1 * time.Hour)
// Simulate errors
mockFS.SimulateError("/path", os.ErrPermission)// Valid test data
state := arctest.ValidState()
history := arctest.ValidHistory()
// Test data with specific characteristics
stateWithResources := arctest.StateWithResources(5)
historyWithOps := arctest.HistoryWithOperations(100)
// Invalid/corrupted data for error testing
corruptedYAML := arctest.CorruptedStateYAML()// File assertions
arctest.AssertFileExists(t, "/path/to/file")
arctest.AssertFileContent(t, path, expectedBytes)
// YAML assertions (ignores formatting)
arctest.AssertYAMLEqual(t, expectedState, actualState)
// Directory assertions
arctest.AssertDirExists(t, "/path/to/dir")# Run all tests with verbose output
go test -v ./...
# Run tests with race detector
go test -race ./...
# Run tests in a specific package
go test -v ./pkg/state
# Run a specific test
go test -v -run TestMyFunction ./pkg/mypackage
# Run integration tests
go test -v -tags=integration ./...
# Run with coverage
go test -coverprofile=coverage.txt ./...
go tool cover -html=coverage.txt
# Run benchmarks
go test -bench=. -benchmem ./...make test # Run all unit tests
make test-coverage # Run with coverage report
make test-coverage-html # Generate HTML coverage
make test-integration # Run integration tests
make test-bench # Run benchmarksTests run automatically on:
- Every commit (via pre-commit hook)
- Every pull request (GitHub Actions)
- Before release (release workflow)
| Priority | Package | Target | Rationale |
|---|---|---|---|
| P0 | pkg/state |
75%+ | Critical state management |
| P0 | pkg/state/storage |
75%+ | File I/O correctness |
| P1 | internal/state |
70%+ | App configuration |
| P1 | pkg/cli |
60%+ | Command execution |
| P2 | pkg/ui/* |
40-50% | Visual output, hard to test |
High Priority:
- State management (CRUD operations)
- File I/O operations
- Data validation and parsing
- Error handling paths
- Business logic
Low Priority:
- UI rendering (visual output)
- Banner generation
- Color/theme selection
- Simple getter/setter methods
# Terminal summary
make test-coverage
# HTML report (detailed view)
make test-coverage-html
open coverage.html
# Package-level coverage
go test -cover ./pkg/state- Use descriptive test names:
TestStorageReadState_MissingFile_ReturnsDefault - Use require for critical checks: Early exit on failure
- Use assert for non-critical checks: Continue running other assertions
- Use t.Helper(): Mark helper functions for better error messages
- Use t.TempDir(): Automatic cleanup of test directories
- Test error paths: Ensure errors are handled correctly
- Use table-driven tests: Multiple scenarios in one test
- Leverage test utilities: Reduce duplication with helpers
- Don't use time.Sleep(): Use MockClock or channels for synchronization
- Don't create global state: Each test should be independent
- Don't use real file paths: Use t.TempDir() or mocks
- Don't test implementation details: Test behavior, not internals
- Don't duplicate test code: Extract common patterns into helpers
- Don't skip test cleanup: Always clean up resources
- Don't write flaky tests: Ensure deterministic outcomes
// Use require for critical checks (stops test on failure)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, expected, actual)
// Use assert for non-critical checks (continues on failure)
assert.Equal(t, expected, actual)
assert.True(t, condition)
assert.Contains(t, slice, item)Problem: Test suite takes > 10 seconds
Solutions:
- Use mocks instead of real dependencies
- Separate integration tests with build tags
- Run tests in parallel:
t.Parallel() - Use
testing.Short()for quick smoke tests
Problem: Tests pass sometimes, fail other times
Common Causes:
- Race conditions (use
-raceto detect) - Time-dependent code (use MockClock)
- Uninitialized state (check setup/teardown)
- External dependencies (use mocks)
Problem: Coverage below 60% threshold
Solutions:
- Focus on critical packages first (state management)
- Test error paths (often missed)
- Add integration tests for workflows
- Don't test trivial code (getters/setters)
Problem: import cycle not allowed
Solution:
- Move test utilities to
internal/testing/ - Use interfaces to break dependencies
- Restructure packages if necessary
func TestResourceValidation(t *testing.T) {
r := Resource{
ID: "res-123",
Type: "compute",
Name: "My Resource",
}
require.Equal(t, "res-123", r.ID)
require.Equal(t, "compute", r.Type)
}func TestParseVersion(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"valid", "1", 1, false},
{"invalid", "abc", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func (t *testing.T) {
got, err := ParseVersion(tt.input)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}func TestStorageWithMockFS(t *testing.T) {
mockFS := arctest.NewMockFS()
mockFS.WriteFile("state.yaml", []byte("version: 1"), 0644)
data, err := mockFS.ReadFile("state.yaml")
require.NoError(t, err)
require.Contains(t, string(data), "version: 1")
}//go:build integration
func TestEndToEndWorkflow(t *testing.T) {
dir := t.TempDir()
// Create storage
storage := NewStorage(dir)
// Write state
state := arctest.ValidState()
err := storage.WriteState(state)
require.NoError(t, err)
// Read back
loaded, err := storage.ReadState()
require.NoError(t, err)
arctest.AssertYAMLEqual(t, state, loaded)
}When contributing tests:
- Follow existing patterns and conventions
- Write clear, descriptive test names
- Add tests for all new features
- Ensure tests pass:
make test - Check coverage:
make test-coverage - Run pre-commit checks:
make pre-commit
Questions? Check the Development Guide or open an issue.