API / plans /testing-infrastructure.md
sshinmen's picture
Update HF Spaces deployment with latest changes
2e6b65c
|
Raw
History Blame Contribute Delete
13.3 kB
# 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`