| # 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` |
|
|