Test Coverage Gaps Specification¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
- Date
- 21 March 2026
- Status
- DRAFT
Overview¶
Code review identified several packages with insufficient test coverage. While some packages have strong coverage, others โ particularly those involving external service interactions โ have minimal or no tests. This spec defines a prioritised plan to achieve 90%+ coverage across all pkg/ packages.
Current Coverage Gaps¶
| Package | Estimated Coverage | Gap |
|---|---|---|
pkg/version |
Low | Pure functions โ easy to test, no mocking needed |
pkg/vcs/gitlab |
Low | HTTP client interactions โ needs mock server |
pkg/vcs/release |
Low | Provider abstraction layer โ needs interface mocks |
pkg/chat (providers) |
Medium | Ask()/Chat() paths โ needs mock HTTP servers |
pkg/controls/grpc |
Low | gRPC server lifecycle โ needs test server setup |
pkg/controls/http |
Low | HTTP server lifecycle โ needs httptest.Server |
pkg/docs |
Medium | MkDocs parsing edge cases |
Design Decisions¶
Mock HTTP servers over recorded responses: Use httptest.Server and custom handlers rather than recorded response fixtures. This allows testing error paths, timeouts, and edge cases that recordings cannot cover.
Table-driven tests: All test suites use table-driven patterns for consistency and easy extension.
No external service dependencies: All tests must run offline. Mock all HTTP, gRPC, and filesystem interactions.
Race detector mandatory: All new tests must pass with -race since many of these packages involve concurrency.
Public API Changes¶
None. This spec adds tests only.
Internal Implementation¶
Priority 1: pkg/version¶
Pure functions with no external dependencies โ highest value per effort.
func TestCompareVersions(t *testing.T) {
tests := []struct {
name string
a, b string
expected int
}{
{"equal", "1.0.0", "1.0.0", 0},
{"a greater major", "2.0.0", "1.0.0", 1},
{"b greater minor", "1.0.0", "1.1.0", -1},
{"a greater patch", "1.0.2", "1.0.1", 1},
{"prerelease vs release", "1.0.0-beta", "1.0.0", -1},
{"v prefix", "v1.0.0", "1.0.0", 0},
{"empty a", "", "1.0.0", -1},
{"empty b", "1.0.0", "", 1},
{"both empty", "", "", 0},
{"invalid a", "not-a-version", "1.0.0", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CompareVersions(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsDevelopment(t *testing.T) {
tests := []struct {
name string
version string
isDev bool
}{
{"dev version", "dev", true},
{"development", "development", true},
{"release", "1.2.3", false},
{"empty", "", true},
}
// ...
}
Priority 2: pkg/vcs/gitlab¶
Mock HTTP server for GitLab API interactions.
func TestGitLabClient_CreateRelease(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && strings.Contains(r.URL.Path, "/releases"):
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"tag_name": "v1.0.0"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := NewGitLabClient(server.URL, "test-token")
err := client.CreateRelease(context.Background(), "v1.0.0", "Release notes")
assert.NoError(t, err)
}
func TestGitLabClient_CreateRelease_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
client := NewGitLabClient(server.URL, "bad-token")
err := client.CreateRelease(context.Background(), "v1.0.0", "notes")
assert.Error(t, err)
}
Priority 3: pkg/vcs/release¶
Test the provider abstraction with mock implementations.
type mockReleaseProvider struct {
createFunc func(ctx context.Context, tag, notes string) error
latestFunc func(ctx context.Context) (string, error)
}
func TestReleaseManager_Create(t *testing.T) {
tests := []struct {
name string
provider mockReleaseProvider
tag string
expectErr bool
}{
{
name: "success",
provider: mockReleaseProvider{
createFunc: func(ctx context.Context, tag, notes string) error { return nil },
},
tag: "v1.0.0",
},
{
name: "provider error",
provider: mockReleaseProvider{
createFunc: func(ctx context.Context, tag, notes string) error {
return errors.New("API error")
},
},
tag: "v1.0.0",
expectErr: true,
},
}
// ...
}
Priority 4: pkg/chat Providers¶
Mock HTTP servers for each provider's API.
func TestClaude_Ask_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"content": []map[string]string{{"type": "text", "text": `{"answer": "42"}`}},
"stop_reason": "end_turn",
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := newTestClaudeClient(t, server.URL)
var result struct{ Answer string }
err := client.Ask(context.Background(), "What is the answer?", &result)
assert.NoError(t, err)
assert.Equal(t, "42", result.Answer)
}
func TestClaude_Ask_ContextCancelled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second) // simulate slow response
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
client := newTestClaudeClient(t, server.URL)
var result string
err := client.Ask(ctx, "question", &result)
assert.Error(t, err)
}
Similar patterns for OpenAI and Gemini with their respective API response formats.
Priority 5: pkg/controls/grpc and pkg/controls/http¶
func TestHTTPServer_StartStop(t *testing.T) {
ctrl := controls.NewController(context.Background())
srv := NewHTTPServer(ctrl, ":0") // random port
ctrl.Start()
// Verify server is accepting connections
// ...
ctrl.Stop()
// Verify server has shut down
}
func TestGRPCServer_StartStop(t *testing.T) {
ctrl := controls.NewController(context.Background())
srv := NewGRPCServer(ctrl, ":0")
ctrl.Start()
// Verify gRPC server is serving
// ...
ctrl.Stop()
}
Priority 6: pkg/docs¶
Edge cases in MkDocs nav parsing.
func TestParseNav_EmptyNav(t *testing.T) {
result := ParseNav([]byte("nav: []"))
assert.Empty(t, result)
}
func TestParseNav_NestedSections(t *testing.T) {
yaml := `nav:
- Home: index.md
- Guide:
- Getting Started: guide/start.md
- Advanced:
- Plugins: guide/advanced/plugins.md`
result := ParseNav([]byte(yaml))
assert.Len(t, result, 2)
// verify nested structure
}
func TestParseNav_InvalidYAML(t *testing.T) {
result := ParseNav([]byte("not: valid: yaml: ["))
assert.Empty(t, result)
}
Project Structure¶
pkg/version/
โโโ version_test.go โ MODIFIED: comprehensive table-driven tests
pkg/vcs/gitlab/
โโโ gitlab_test.go โ NEW/MODIFIED: mock HTTP server tests
pkg/vcs/release/
โโโ release_test.go โ NEW/MODIFIED: mock provider tests
pkg/chat/
โโโ claude_test.go โ MODIFIED: mock API server tests
โโโ openai_test.go โ MODIFIED: mock API server tests
โโโ gemini_test.go โ MODIFIED: mock API server tests
โโโ testhelpers_test.go โ NEW: shared test utilities (mock servers, factories)
pkg/controls/grpc/
โโโ grpc_test.go โ NEW/MODIFIED: server lifecycle tests
pkg/controls/http/
โโโ http_test.go โ NEW/MODIFIED: server lifecycle tests
pkg/docs/
โโโ docs_test.go โ MODIFIED: edge case tests
Testing Strategy¶
Test Categories¶
| Category | Packages | Approach |
|---|---|---|
| Pure functions | pkg/version |
Table-driven, no mocks |
| HTTP clients | pkg/vcs/gitlab, pkg/chat/* |
httptest.Server with custom handlers |
| Abstractions | pkg/vcs/release |
Interface mocks |
| Servers | pkg/controls/grpc, pkg/controls/http |
Start/stop lifecycle, port 0 |
| Parsers | pkg/docs |
Edge cases, malformed input |
Shared Test Helpers¶
// testhelpers_test.go in pkg/chat/
func newMockAPIServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return server
}
func newTestClaudeClient(t *testing.T, baseURL string) ChatClient {
t.Helper()
// construct client pointing at mock server
}
Coverage Targets¶
| Package | Current (est.) | Target |
|---|---|---|
pkg/version |
~30% | 95%+ |
pkg/vcs/gitlab |
~20% | 90%+ |
pkg/vcs/release |
~30% | 90%+ |
pkg/chat |
~50% | 90%+ |
pkg/controls/grpc |
~10% | 80%+ |
pkg/controls/http |
~10% | 80%+ |
pkg/docs |
~60% | 90%+ |
Coverage¶
- Overall target: 90%+ for
pkg/.
Linting¶
golangci-lint run --fixmust pass.- No new
nolintdirectives in test files. - Test files should follow the same linting rules as production code (except
fmt.Errorfis acceptable in tests).
Documentation¶
- Godoc for shared test helpers explaining their purpose.
- Comments in test tables explaining non-obvious test cases.
- No user-facing documentation changes.
Backwards Compatibility¶
- No breaking changes. Tests only.
Future Considerations¶
- Integration tests: A separate
integration_test.gobuild tag for tests that hit real APIs (with API keys from CI secrets). - Coverage CI gate: Add a CI step that fails if coverage drops below threshold.
- Fuzz testing:
pkg/version/CompareVersionsandpkg/docs/ParseNavare good candidates for Go's native fuzzing. - Benchmark tests: Chat provider response parsing could benefit from benchmarks if performance becomes a concern.
Implementation Phases¶
Phase 1 โ Pure Functions (pkg/version)¶
- Add comprehensive table-driven tests for all exported functions
- Achieve 95%+ coverage
Phase 2 โ VCS Packages¶
- Add mock HTTP server tests for
pkg/vcs/gitlab - Add mock provider tests for
pkg/vcs/release - Achieve 90%+ coverage for both
Phase 3 โ Chat Providers¶
- Create shared test helpers (
testhelpers_test.go) - Add mock API server tests for Claude, OpenAI, Gemini
- Test error paths, timeouts, context cancellation
- Achieve 90%+ coverage
Phase 4 โ Controls Servers¶
- Add lifecycle tests for
pkg/controls/http - Add lifecycle tests for
pkg/controls/grpc - Achieve 80%+ coverage
Phase 5 โ Docs Package¶
- Add edge case tests for nav parsing
- Add malformed input tests
- Achieve 90%+ coverage
Verification¶
# Full test suite with race detector
go test -race ./...
# Coverage report
go test -coverprofile=coverage.out ./pkg/...
go tool cover -func=coverage.out | tail -1 # total coverage
# Per-package coverage
go test -coverprofile=coverage.out ./pkg/version/...
go tool cover -func=coverage.out
go test -coverprofile=coverage.out ./pkg/vcs/...
go tool cover -func=coverage.out
go test -coverprofile=coverage.out ./pkg/chat/...
go tool cover -func=coverage.out
go test -coverprofile=coverage.out ./pkg/controls/...
go tool cover -func=coverage.out
# Lint
golangci-lint run --fix