Skip to content

Commit f7a4dcf

Browse files
committed
docs: DEVELOPMENT.md
1 parent 459f97c commit f7a4dcf

1 file changed

Lines changed: 307 additions & 6 deletions

File tree

docs/DEVELOPMENT.md

Lines changed: 307 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,24 @@ go tool cover -func=coverage.out
139139

140140
### Current Test Coverage
141141

142-
| Package | Coverage |
143-
|---------|----------|
144-
| `config` | 97.8% |
145-
| `errors` | 100% |
146-
| `executor` | 82.5% |
147-
| `gitcmd` | 100% |
142+
| Package | Coverage | Notes |
143+
|---------|----------|-------|
144+
| `config` | 97.8% | Configuration parsing and validation |
145+
| `errors` | 100% | Custom error types (GitError, ConfigError, RetryError, APIError) |
146+
| `executor` | 98.2% | Command execution abstraction and mocking |
147+
| `gitcmd` | 100% | Git command builders |
148+
| `cmd` | 0% | Entry point (tested via integration tests) |
149+
| `git` | 0% | Git operations (tested via CI workflows) |
150+
| `git/pr` | 0% | PR operations (tested via CI workflows) |
151+
152+
**Why some packages have 0% coverage:**
153+
- **cmd/main.go**: Application entry point with signal handling - tested through integration tests in CI
154+
- **internal/git**: Core git operations with heavy external dependencies (git commands, filesystem) - validated through real workflow execution
155+
- **internal/git/pr**: GitHub API interactions requiring network calls - tested end-to-end in CI workflows
156+
157+
**Testing Strategy:**
158+
- **Unit Tests** (High Coverage): Pure logic without external dependencies
159+
- **Integration Tests** (CI/CD): Components requiring git, filesystem, or API interactions
148160

149161
---
150162

@@ -384,3 +396,292 @@ go run ./cmd/main.go
384396
git tag -a v1.0.0 -m "Release v1.0.0"
385397
git push origin v1.0.0
386398
```
399+
400+
---
401+
402+
## Improving Test Coverage
403+
404+
<br/>
405+
406+
### Coverage Analysis
407+
408+
<br/>
409+
410+
#### Finding Low Coverage Areas
411+
412+
```bash
413+
# Generate detailed coverage report
414+
go test -v -coverprofile=coverage.out ./internal/executor
415+
416+
# View function-level coverage
417+
go tool cover -func=coverage.out | grep executor
418+
```
419+
420+
**Example Output:**
421+
```
422+
executor.go:44: ExecuteWithStreams 0.0%
423+
mock_executor.go:54: ExecuteWithOutput 71.4%
424+
mock_executor.go:73: ExecuteWithStreams 70.0%
425+
mock_executor.go:120: GetLastCommand 66.7%
426+
```
427+
428+
<br/>
429+
430+
#### Common Coverage Issues and Solutions
431+
432+
**1. Uncovered Error Paths**
433+
434+
```go
435+
// Before: Only testing success case (71.4% coverage)
436+
func TestMockExecutor_ExecuteWithOutput(t *testing.T) {
437+
mock := NewMockExecutor()
438+
mock.SetOutput([]byte("output"), "git", "log")
439+
440+
output, err := mock.ExecuteWithOutput("git", "log")
441+
if err != nil {
442+
t.Errorf("Expected no error, got %v", err)
443+
}
444+
}
445+
446+
// After: Test success + error + default cases (100% coverage)
447+
func TestMockExecutor_ExecuteWithOutput(t *testing.T) {
448+
mock := NewMockExecutor()
449+
450+
// Test configured output
451+
mock.SetOutput([]byte("output"), "git", "log")
452+
output, err := mock.ExecuteWithOutput("git", "log")
453+
// ... assertions
454+
455+
// Test error case
456+
mock.Reset()
457+
expectedErr := errors.New("output error")
458+
mock.SetError(expectedErr, "git", "status")
459+
_, err = mock.ExecuteWithOutput("git", "status")
460+
// ... assertions
461+
462+
// Test unconfigured command (default behavior)
463+
mock.Reset()
464+
output, err = mock.ExecuteWithOutput("git", "branch")
465+
// ... assertions
466+
}
467+
```
468+
469+
**2. Missing Nil Checks**
470+
471+
```go
472+
// Before: GetLastCommand test causing panic (66.7% coverage)
473+
func TestMockExecutor_GetLastCommand(t *testing.T) {
474+
mock := NewMockExecutor()
475+
476+
// This caused panic - cmd was nil!
477+
cmd := mock.GetLastCommand()
478+
if cmd.Name != "" { // ❌ Panic: nil pointer dereference
479+
t.Error("Expected empty")
480+
}
481+
}
482+
483+
// After: Proper nil handling (100% coverage)
484+
func TestMockExecutor_GetLastCommand(t *testing.T) {
485+
mock := NewMockExecutor()
486+
487+
// Test with no commands
488+
cmd := mock.GetLastCommand()
489+
if cmd != nil { // ✅ Check for nil first
490+
t.Errorf("Expected nil, got %v", cmd)
491+
}
492+
493+
// Test with commands
494+
mock.Execute("git", "status")
495+
cmd = mock.GetLastCommand()
496+
if cmd == nil {
497+
t.Fatal("Expected command, got nil")
498+
}
499+
// ... assertions
500+
}
501+
```
502+
503+
**3. Untested Stream Operations**
504+
505+
```go
506+
// Before: RealExecutor_ExecuteWithStreams not tested (0% coverage)
507+
// No test existed for this function
508+
509+
// After: Add stream testing (100% coverage)
510+
func TestRealExecutor_ExecuteWithStreams(t *testing.T) {
511+
executor := NewRealExecutor()
512+
var stdout, stderr bytes.Buffer
513+
514+
// Test successful command
515+
err := executor.ExecuteWithStreams("echo", []string{"hello"}, &stdout, &stderr)
516+
if err != nil {
517+
t.Errorf("Expected no error, got %v", err)
518+
}
519+
if stdout.Len() == 0 {
520+
t.Error("Expected stdout output")
521+
}
522+
523+
// Test failing command
524+
stdout.Reset()
525+
stderr.Reset()
526+
err = executor.ExecuteWithStreams("false", []string{}, &stdout, &stderr)
527+
if err == nil {
528+
t.Error("Expected error for 'false' command")
529+
}
530+
}
531+
```
532+
533+
**4. Missing Edge Cases**
534+
535+
```go
536+
// Before: Only testing main flow (70% coverage)
537+
func TestMockExecutor_ExecuteWithStreams(t *testing.T) {
538+
mock := NewMockExecutor()
539+
var stdout bytes.Buffer
540+
541+
mock.SetStreamOutput("output", "git", "diff")
542+
err := mock.ExecuteWithStreams("git", []string{"diff"}, &stdout, nil)
543+
// ... assertions
544+
}
545+
546+
// After: Test all branches (100% coverage)
547+
func TestMockExecutor_ExecuteWithStreams(t *testing.T) {
548+
mock := NewMockExecutor()
549+
var stdout bytes.Buffer
550+
551+
// Test configured stream output
552+
mock.SetStreamOutput("output", "git", "diff")
553+
err := mock.ExecuteWithStreams("git", []string{"diff"}, &stdout, nil)
554+
// ... assertions
555+
556+
// Test error case
557+
mock.Reset()
558+
expectedErr := errors.New("stream error")
559+
mock.SetError(expectedErr, "git", "push")
560+
err = mock.ExecuteWithStreams("git", []string{"push"}, &stdout, nil)
561+
// ... assertions
562+
563+
// Test unconfigured command
564+
mock.Reset()
565+
stdout.Reset()
566+
err = mock.ExecuteWithStreams("git", []string{"status"}, &stdout, nil)
567+
// ... assertions
568+
}
569+
```
570+
571+
<br/>
572+
573+
### Coverage Improvement Workflow
574+
575+
1. **Identify low coverage areas:**
576+
```bash
577+
go test -coverprofile=coverage.out ./internal/executor
578+
go tool cover -func=coverage.out | grep -E "^.*\s+[0-8][0-9]\.[0-9]%$"
579+
```
580+
581+
2. **Analyze uncovered lines:**
582+
```bash
583+
go tool cover -html=coverage.out
584+
# Opens browser showing covered (green) vs uncovered (red) lines
585+
```
586+
587+
3. **Add missing test cases:**
588+
- Error paths
589+
- Edge cases (nil, empty, boundary values)
590+
- Default behaviors
591+
- All conditional branches
592+
593+
4. **Verify improvement:**
594+
```bash
595+
go test -cover ./internal/executor
596+
# Should show higher percentage
597+
```
598+
599+
<br/>
600+
601+
### Example: Improving executor Coverage (82.5% → 98.2%)
602+
603+
#### Steps taken:
604+
605+
1. **Identified gaps** using `go tool cover -func`:
606+
- `ExecuteWithStreams` (RealExecutor): 0% → Added test
607+
- `ExecuteWithOutput` (MockExecutor): 71.4% → Added error/default cases
608+
- `ExecuteWithStreams` (MockExecutor): 70% → Added error/unconfigured cases
609+
- `GetLastCommand`: 66.7% → Fixed nil handling
610+
611+
2. **Added comprehensive tests:**
612+
- Created `TestRealExecutor_ExecuteWithStreams`
613+
- Enhanced `TestMockExecutor_ExecuteWithOutput` with 3 scenarios
614+
- Enhanced `TestMockExecutor_ExecuteWithStreams` with 3 scenarios
615+
- Fixed `TestMockExecutor_GetLastCommand` nil check
616+
- Added `TestMockExecutor_MultipleCommands` for integration
617+
618+
3. **Result:** 82.5% → 98.2% (+15.7 percentage points)
619+
620+
---
621+
622+
## Code Quality Improvements
623+
624+
<br/>
625+
626+
### Refactoring for Better Testability
627+
628+
#### 1. Error Handling Consistency
629+
630+
```go
631+
// Before: Mixed error handling styles
632+
return fmt.Errorf("operation failed: %v", err)
633+
return errors.New("failed", err)
634+
635+
// After: Consistent custom error types
636+
return errors.New("operation", err)
637+
return errors.NewWithPath("operation", path, err)
638+
return errors.NewConfig("validation message")
639+
return errors.NewWithContext("retry failed", attempts, lastErr)
640+
```
641+
642+
#### 2. Magic Numbers → Constants
643+
644+
```go
645+
// Before: Hard-coded values
646+
time.Sleep(time.Second * time.Duration(i+1))
647+
os.MkdirAll(dir, 0755)
648+
os.WriteFile(path, content, 0644)
649+
650+
// After: Named constants
651+
const (
652+
permDir = 0755
653+
permFile = 0644
654+
retryBaseDelay = time.Second
655+
)
656+
657+
time.Sleep(retryBaseDelay * time.Duration(i+1))
658+
os.MkdirAll(dir, permDir)
659+
os.WriteFile(path, content, permFile)
660+
```
661+
662+
#### 3. Error Types Enhancement
663+
664+
Added new error types for better error handling:
665+
666+
```go
667+
// RetryError for retry failures
668+
type RetryError struct {
669+
Attempts int
670+
LastErr error
671+
Message string
672+
}
673+
674+
// NewConfig for configuration errors without field
675+
func NewConfig(message string) *ConfigError {
676+
return &ConfigError{Message: message}
677+
}
678+
679+
// NewWithContext for retry context
680+
func NewWithContext(message string, attempts int, lastErr error) *RetryError {
681+
return &RetryError{
682+
Message: message,
683+
Attempts: attempts,
684+
LastErr: lastErr,
685+
}
686+
}
687+
```

0 commit comments

Comments
 (0)