# Testing Infrastructure Improvement Plan ## Current State Analysis - **Total Go files**: 377 - **Test files**: 50 (~13% coverage) - **Issues**: - Low test coverage - Heavy use of `init()` functions making tests hard to isolate - External dependencies not mocked - No contract tests between API formats - Integration tests missing for storage backends ## Target Metrics - **Unit test coverage**: 70%+ - **Integration test coverage**: Key paths covered - **Test execution time**: < 2 minutes for full suite - **Flaky tests**: 0% ## Phase 1: Testing Framework Setup (Week 1) ### 1.1 Test Utilities Package **File**: `internal/testutil/helpers.go` ```go package testutil import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Must panics if error is not nil (use in test setup) func Must(t *testing.T, err error) { t.Helper() require.NoError(t, err) } // Fixture loads test fixture from file func Fixture(t *testing.T, name string) []byte { t.Helper() data, err := os.ReadFile(filepath.Join("testdata", name)) require.NoError(t, err) return data } // TempDir creates a temp directory that auto-cleans func TempDir(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("", "cliproxy-test-*") require.NoError(t, err) t.Cleanup(func() { os.RemoveAll(dir) }) return dir } // Context returns a context with timeout for tests func Context(t *testing.T) context.Context { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) t.Cleanup(cancel) return ctx } ``` ### 1.2 Mock Generation **File**: `Makefile` additions ```makefile .PHONY: mocks mocks: go install github.com/vektra/mockery/v2@latest mockery --config=.mockery.yml ``` **File**: `.mockery.yml` ```yaml with-expecter: true keeptree: false dir: internal/domain/ports outpkg: mocks output: internal/testutil/mocks packages: github.com/echyai/cliproxyapi/internal/domain/ports: interfaces: AuthRepository: ConfigRepository: TokenStore: Translator: ``` ### 1.3 Test Containers for Integration Tests **File**: `internal/testutil/containers.go` ```go package testutil import ( "context" "database/sql" "testing" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/modules/minio" ) // PostgresContainer wraps test postgres container type PostgresContainer struct { *postgres.PostgresContainer ConnStr string } func NewPostgres(t *testing.T) *PostgresContainer { t.Helper() ctx := context.Background() container, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("test"), postgres.WithUsername("test"), postgres.WithPassword("test"), ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, container.Terminate(ctx)) }) connStr, err := container.ConnectionString(ctx) require.NoError(t, err) return &PostgresContainer{ PostgresContainer: container, ConnStr: connStr, } } func (p *PostgresContainer) DB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("postgres", p.ConnStr) require.NoError(t, err) t.Cleanup(func() { db.Close() }) return db } // MinIOContainer wraps test minio container type MinIOContainer struct { *minio.MinioContainer Endpoint string } func NewMinIO(t *testing.T) *MinIOContainer { t.Helper() ctx := context.Background() container, err := minio.Run(ctx, "minio/minio:latest") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, container.Terminate(ctx)) }) endpoint, err := container.Endpoint(ctx, "http") require.NoError(t, err) return &MinIOContainer{ MinioContainer: container, Endpoint: endpoint, } } ``` ## Phase 2: Unit Testing Patterns (Week 2-3) ### 2.1 Table-Driven Tests **Example**: `internal/translator/gemini/translator_test.go` ```go package gemini import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTranslator_ConvertRequest(t *testing.T) { tests := []struct { name string input openai.ChatCompletionRequest want *gemini.GenerateContentRequest wantErr bool errMsg string }{ { name: "simple request with single message", input: openai.ChatCompletionRequest{ Model: "gemini-pro", Messages: []openai.ChatMessage{ {Role: "user", Content: "Hello"}, }, }, want: &gemini.GenerateContentRequest{ Model: "gemini-pro", Contents: []gemini.Content{ {Role: "user", Parts: []gemini.Part{{Text: "Hello"}}}, }, }, }, { name: "system message conversion", input: openai.ChatCompletionRequest{ Model: "gemini-pro", Messages: []openai.ChatMessage{ {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, }, }, want: &gemini.GenerateContentRequest{ Model: "gemini-pro", SystemInstruction: &gemini.Content{ Parts: []gemini.Part{{Text: "You are helpful"}}, }, Contents: []gemini.Content{ {Role: "user", Parts: []gemini.Part{{Text: "Hi"}}}, }, }, }, { name: "unsupported model", input: openai.ChatCompletionRequest{ Model: "unknown-model", }, wantErr: true, errMsg: "unsupported model", }, { name: "max tokens exceeded", input: openai.ChatCompletionRequest{ Model: "gemini-pro", MaxTokens: 1000000, Messages: []openai.ChatMessage{{Role: "user", Content: "Hi"}}, }, wantErr: true, errMsg: "max tokens exceeds limit", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr := NewTranslator() got, err := tr.ConvertRequest(tt.input) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errMsg) return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } ``` ### 2.2 Mock-Based Tests **Example**: `internal/application/usecase/auth_test.go` ```go package usecase import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/echyai/cliproxyapi/internal/domain/models" "github.com/echyai/cliproxyapi/internal/testutil/mocks" ) func TestAuthUseCase_GetAuth(t *testing.T) { tests := []struct { name string id string mock func(*mocks.AuthRepository) want *models.Auth wantErr error }{ { name: "existing auth", id: "auth-123", mock: func(r *mocks.AuthRepository) { r.On("FindByID", mock.Anything, "auth-123"). Return(&models.Auth{ ID: "auth-123", Provider: "gemini", Status: models.AuthStatusActive, }, nil) }, want: &models.Auth{ ID: "auth-123", Provider: "gemini", Status: models.AuthStatusActive, }, }, { name: "not found", id: "auth-missing", mock: func(r *mocks.AuthRepository) { r.On("FindByID", mock.Anything, "auth-missing"). Return(nil, nil) }, wantErr: domainerrors.NewConfigNotFound("auth-missing"), }, { name: "repository error", id: "auth-err", mock: func(r *mocks.AuthRepository) { r.On("FindByID", mock.Anything, "auth-err"). Return(nil, errors.New("db error")) }, wantErr: domainerrors.NewInternalError(errors.New("db error")), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockRepo := mocks.NewAuthRepository(t) tt.mock(mockRepo) uc := NewAuthUseCase(mockRepo) got, err := uc.GetAuth(testutil.Context(t), tt.id) if tt.wantErr != nil { assert.ErrorIs(t, err, tt.wantErr) return } assert.NoError(t, err) assert.Equal(t, tt.want, got) mockRepo.AssertExpectations(t) }) } } ``` ## Phase 3: Integration Tests (Week 3-4) ### 3.1 API Integration Tests **File**: `test/integration/api_test.go` ```go package integration import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/echyai/cliproxyapi/internal/api" "github.com/echyai/cliproxyapi/internal/testutil" ) func TestAPI_ChatCompletions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } // Setup test server gin.SetMode(gin.TestMode) container := testutil.NewPostgres(t) server, err := api.NewTestServer(container.ConnStr) require.NoError(t, err) // Test cases tests := []struct { name string request map[string]interface{} wantStatus int wantFields []string }{ { name: "valid request", request: map[string]interface{}{ "model": "gemini-pro", "messages": []map[string]string{ {"role": "user", "content": "Hello"}, }, }, wantStatus: 200, wantFields: []string{"id", "choices", "usage"}, }, { name: "missing model", request: map[string]interface{}{ "messages": []map[string]string{ {"role": "user", "content": "Hello"}, }, }, wantStatus: 400, wantFields: []string{"error"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body, _ := json.Marshal(tt.request) req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer test-key") w := httptest.NewRecorder() server.Handler.ServeHTTP(w, req) assert.Equal(t, tt.wantStatus, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) for _, field := range tt.wantFields { assert.Contains(t, resp, field) } }) } } ``` ### 3.2 Contract Tests **File**: `test/contract/translator_contract_test.go` ```go package contract import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/echyai/cliproxyapi/internal/translator/gemini" "github.com/echyai/cliproxyapi/internal/translator/openai" ) // TestOpenAIGeminiRoundTrip verifies that translating OpenAI -> Gemini -> OpenAI // preserves essential fields func TestOpenAIGeminiRoundTrip(t *testing.T) { original := openai.ChatCompletionRequest{ Model: "gemini-pro", Messages: []openai.ChatMessage{ {Role: "system", Content: "Be helpful"}, {Role: "user", Content: "Hello"}, {Role: "assistant", Content: "Hi there!"}, {Role: "user", Content: "How are you?"}, }, Temperature: 0.7, MaxTokens: 100, TopP: 0.9, } geminiTranslator := gemini.NewTranslator() // Convert to Gemini geminiReq, err := geminiTranslator.ConvertRequest(original) require.NoError(t, err) // Convert back to OpenAI openaiReq, err := geminiTranslator.ConvertResponse(geminiReq) require.NoError(t, err) // Verify essential fields preserved assert.Equal(t, original.Model, openaiReq.Model) assert.Len(t, openaiReq.Messages, len(original.Messages)) assert.InDelta(t, original.Temperature, openaiReq.Temperature, 0.01) } ``` ## Phase 4: CI/CD Integration (Week 4) ### 4.1 GitHub Actions Workflow **File**: `.github/workflows/test.yml` ```yaml name: Test on: push: branches: [main, develop] pull_request: branches: [main, develop] jobs: unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.24' - name: Run unit tests run: go test -v -race -coverprofile=coverage.out ./internal/... ./sdk/... - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./coverage.out fail_ci_if_error: true integration-test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test POSTGRES_DB: test options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 minio: image: minio/minio env: MINIO_ROOT_USER: test MINIO_ROOT_PASSWORD: testpassword ports: - 9000:9000 options: >- server /data steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.24' - name: Run integration tests run: go test -v ./test/integration/... -tags=integration env: TEST_POSTGRES_URL: postgres://postgres:test@localhost:5432/test?sslmode=disable TEST_MINIO_ENDPOINT: localhost:9000 contract-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.24' - name: Run contract tests run: go test -v ./test/contract/... ``` ## Success Metrics - [ ] 70%+ code coverage - [ ] All critical paths have integration tests - [ ] Contract tests for all translators - [ ] CI pipeline runs tests in < 5 minutes - [ ] Zero flaky tests ## Testing Best Practices 1. **Use subtests** for table-driven tests with `t.Run()` 2. **Cleanup resources** with `t.Cleanup()` 3. **Parallel tests** where possible with `t.Parallel()` 4. **Golden files** for complex JSON responses 5. **Property-based testing** for parsers using `rapid`