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

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

.PHONY: mocks

mocks:
    go install github.com/vektra/mockery/v2@latest
    mockery --config=.mockery.yml

File: .mockery.yml

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

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

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

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

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

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

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