Testing Guide
This guide covers testing practices, workflows, and requirements for the LLM Proxy project. The project follows strict Test-Driven Development (TDD) principles with a minimum coverage requirement of 90%.
Testing Philosophy
Test-Driven Development (TDD)
Mandatory TDD Workflow:
- Red: Write a failing test first
- Green: Implement minimal code to make the test pass
- Refactor: Improve code while maintaining passing tests
No exceptions: All features and bug fixes must start with a failing test.
Coverage Requirements
- Minimum Coverage: 90% for all packages under
internal/ - Aggregate Coverage: Currently at 75.4%, target is 90%+
- Coverage Enforcement: CI fails if coverage drops below threshold
- New Code: Must maintain or improve coverage percentage
Testing Levels
1. Unit Tests
Purpose: Test individual functions, methods, and components in isolation
Location: *_test.go files in the same package as the implementation
Characteristics:
- Fast execution (< 1ms per test)
- No external dependencies (use mocks/stubs)
- Test pure functions and business logic
- Use table-driven tests for multiple scenarios
Example Structure:
func TestTokenValidator_ValidateToken(t *testing.T) {
tests := []struct {
name string
token string
expectedResult ValidationResult
expectedError error
setup func(*testing.T) *mockStore
}{
{
name: "valid_token",
token: "valid-token-123",
expectedResult: ValidationResult{Valid: true},
setup: func(t *testing.T) *mockStore {
store := &mockStore{}
store.On("GetToken", mock.Anything).Return(validToken, nil)
return store
},
},
// ... more test cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := tt.setup(t)
validator := NewValidator(store)
result, err := validator.ValidateToken(context.Background(), tt.token)
assert.Equal(t, tt.expectedResult, result)
assert.Equal(t, tt.expectedError, err)
store.AssertExpectations(t)
})
}
}
2. Integration Tests
Purpose: Test package interactions and external integrations
Location: *_integration_test.go files with build tag //go:build integration
Characteristics:
- Test real interactions between components
- Use real database connections (SQLite for tests)
- Test HTTP endpoints with test servers
- Longer execution time acceptable
Example Structure:
//go:build integration
func TestTokenManager_Integration(t *testing.T) {
// Setup real database
db := setupTestDB(t)
defer db.Close()
manager := token.NewManager(db)
// Test complete token lifecycle
token, err := manager.CreateToken(ctx, projectID, duration)
require.NoError(t, err)
result, err := manager.ValidateToken(ctx, token.ID)
require.NoError(t, err)
assert.True(t, result.Valid)
err = manager.RevokeToken(ctx, token.ID)
require.NoError(t, err)
}
2a. PostgreSQL Integration Tests
Purpose: Test database operations against a real PostgreSQL instance
Location: internal/database/*_integration_test.go with build tags //go:build postgres && integration
Prerequisites:
- Docker and Docker Compose installed
- PostgreSQL container running
Running PostgreSQL Tests:
# Full integration test run (starts PostgreSQL, runs tests, stops)
./scripts/run-postgres-integration.sh
# Start PostgreSQL and keep running
./scripts/run-postgres-integration.sh start
# Run tests only (assumes PostgreSQL is running)
./scripts/run-postgres-integration.sh test
# Stop and clean up
./scripts/run-postgres-integration.sh teardown
Manual Test Run:
# Start PostgreSQL with Docker Compose
docker compose --profile postgres up -d postgres
# Wait for PostgreSQL to be ready
docker compose --profile postgres exec postgres pg_isready -U llmproxy -d llmproxy
# Run tests with postgres and integration tags
export TEST_POSTGRES_URL="postgres://llmproxy:secret@localhost:5432/llmproxy?sslmode=disable"
go test -v -race -tags=postgres,integration ./internal/database/...
# Clean up
docker compose --profile postgres down -v
Test Coverage: PostgreSQL integration tests cover:
- Migration application and rollback
- Project CRUD operations
- Token CRUD operations
- Audit event storage and retrieval
- Concurrent database operations
- Connection pool behavior
- Transaction rollback on failure
3. End-to-End (E2E) Tests
Purpose: Test complete user flows and system behavior
Location: test/ directory with CLI and HTTP tests
Characteristics:
- Test complete user workflows
- Use real server instances
- Test CLI commands and outputs
- Validate system behavior under load
Example Structure:
func TestE2E_CompleteWorkflow(t *testing.T) {
// Start real server
server := startTestServer(t)
defer server.Stop()
// Test project creation via CLI
output := runCLI(t, "manage", "project", "create", "--name", "test-project")
projectID := extractProjectID(output)
// Test token generation
output = runCLI(t, "manage", "token", "generate", "--project-id", projectID)
token := extractToken(output)
// Test proxy request with token
resp := makeProxyRequest(t, server.URL, token, "/v1/models")
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
Test Organization
File Naming Conventions
component_test.go: Unit tests forcomponent.gocomponent_integration_test.go: Integration testscomponent_bench_test.go: Benchmark teststestdata/: Static test data and fixtures
Test Function Naming
TestFunctionName: Basic functionality testTestFunctionName_Scenario: Specific scenario testTestFunctionName_EdgeCase: Edge case handlingBenchmarkFunctionName: Performance benchmarks
Test Utilities
Common Test Helpers (internal/testing/):
// Database helpers
func SetupTestDB(t *testing.T) *sql.DB
func CleanupTestDB(t *testing.T, db *sql.DB)
// HTTP helpers
func NewTestServer(t *testing.T, handler http.Handler) *httptest.Server
func MakeRequest(t *testing.T, server *httptest.Server, method, path string) *http.Response
// CLI helpers
func RunCLI(t *testing.T, args ...string) string
func ExpectCLIError(t *testing.T, expectedError string, args ...string)
Running Tests
Local Development
# Run all tests with race detection
make test
# Run with coverage reporting
make test-coverage
# Run only unit tests (fast)
go test -short ./...
# Run integration tests
go test -tags=integration ./...
# Run specific package tests
go test -v ./internal/token/
# Run specific test function
go test -v -run TestTokenValidator_ValidateToken ./internal/token/
# Run benchmarks
go test -bench=. ./internal/token/
# Generate coverage HTML report
make test-coverage-html
CI/CD Testing
GitHub Actions Workflow (.github/workflows/test.yml):
- Unit Tests: Fast tests on multiple Go versions
- Integration Tests: Tests with real database
- Race Detection: All tests run with
-raceflag - Coverage Reporting: Upload coverage to artifacts
- Benchmark Testing: Performance regression detection
Test Data Management
Test Database Setup
SQLite for Tests:
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
// Run migrations
err = runMigrations(db)
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return db
}
Test Fixtures
Using testdata/:
testdata/
├── valid_token.json # Valid token test data
├── expired_token.json # Expired token scenarios
├── api_responses/ # Mock API responses
│ ├── openai_models.json
│ └── openai_chat.json
└── sql/ # Test database fixtures
└── sample_data.sql
Mocking and Test Doubles
Interface-Based Mocking
Example Interface:
type TokenStore interface {
GetToken(ctx context.Context, id string) (*Token, error)
CreateToken(ctx context.Context, token *Token) error
UpdateToken(ctx context.Context, token *Token) error
DeleteToken(ctx context.Context, id string) error
}
Mock Implementation:
type MockTokenStore struct {
mock.Mock
}
func (m *MockTokenStore) GetToken(ctx context.Context, id string) (*Token, error) {
args := m.Called(ctx, id)
return args.Get(0).(*Token), args.Error(1)
}
// Use testify/mock for behavior verification
func TestWithMock(t *testing.T) {
mockStore := &MockTokenStore{}
mockStore.On("GetToken", mock.Anything, "token-123").Return(validToken, nil)
// Test code using mockStore
result, err := service.ProcessToken(mockStore, "token-123")
assert.NoError(t, err)
mockStore.AssertExpectations(t)
}
HTTP Test Doubles
Test Server Setup:
func setupMockOpenAI(t *testing.T) *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(loadTestData(t, "openai_models.json"))
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}
Coverage Measurement
Current Coverage Status
Package Coverage (as of latest run):
internal/token: 95.2% ✅internal/database: 88.7% ❌ (needs improvement)internal/proxy: 92.1% ✅internal/eventbus: 89.3% ❌ (needs improvement)- Overall: 75.4% ❌ (target: 90%+)
Coverage Commands
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View coverage by function
go tool cover -func=coverage.out
# Generate HTML coverage report
go tool cover -html=coverage.out -o coverage.html
# Coverage for specific packages only
go test -coverprofile=coverage.out ./internal/...
Coverage Analysis
Exclude from Coverage:
- Generated code (protobuf, mock files)
- Main functions and CLI entry points
- Error handling for “impossible” conditions
- Deprecated code scheduled for removal
Focus Areas for Coverage:
- Business logic in
internal/token/,internal/database/ - HTTP handlers and middleware
- Error handling and edge cases
- Configuration validation
Performance Testing
Benchmark Tests
CPU Benchmarks:
func BenchmarkTokenValidation(b *testing.B) {
validator := setupValidator(b)
token := "valid-token-123"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := validator.ValidateToken(context.Background(), token)
if err != nil {
b.Fatal(err)
}
}
}
Memory Benchmarks:
func BenchmarkEventPublishing(b *testing.B) {
bus := setupEventBus(b)
event := &Event{/* ... */}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
bus.Publish(context.Background(), *event)
}
}
Load Testing
Using built-in benchmark tool:
# Build benchmark tool
make build
# Run load test
./bin/llm-proxy benchmark \
--url http://localhost:8080 \
--token <test-token> \
--concurrent 10 \
--requests 1000 \
--duration 60s
Test Environment Setup
Local Development Setup
# Install test dependencies
go mod download
# Install testing tools
make tools
# Setup test database
make db-setup
# Run all tests to verify setup
make test
CI Environment
Required Environment Variables:
MANAGEMENT_TOKEN: Test management tokenOPENAI_API_KEY: Test API key (optional, can use mock)DATABASE_PATH: Test database pathLOG_LEVEL: debug (for test visibility)
Docker Testing
Test in Container:
# Build test image
docker build -f Dockerfile.test .
# Run tests in container
docker run --rm -v $(pwd):/app test-image make test
# Run with coverage
docker run --rm -v $(pwd):/app test-image make test-coverage
Debugging Tests
Test Debugging Techniques
Verbose Output:
# Verbose test output
go test -v ./internal/token/
# Show test coverage per function
go test -v -coverprofile=coverage.out ./internal/token/
go tool cover -func=coverage.out | grep -v "100.0%"
Debug Logging in Tests:
func TestWithLogging(t *testing.T) {
// Enable debug logging for tests
logger := zap.NewDevelopment()
// Use logger in component
component := NewComponent(logger)
// Test with detailed logging
result, err := component.DoSomething()
logger.Info("Test result", zap.Any("result", result))
}
Common Test Failures
Race Conditions:
# Run tests with race detector
go test -race ./...
# Run specific test with race detection
go test -race -run TestConcurrentAccess ./internal/token/
Timing Issues:
// Use eventually for async operations
func TestAsyncOperation(t *testing.T) {
result := performAsyncOperation()
// Wait for async completion
assert.Eventually(t, func() bool {
return result.IsComplete()
}, 5*time.Second, 100*time.Millisecond)
}
Continuous Integration
Pre-Push Checks
Required Checks (run via Git hooks or manually):
# Format code
make fmt
# Run linter
make lint
# Run all tests
make test
# Check coverage
make test-coverage
# Integration tests
make integration-test
CI Pipeline
GitHub Actions Matrix:
- Go Versions: 1.23, 1.24
- Platforms: ubuntu-latest, macos-latest
- Test Types: unit, integration, race
- Coverage: Generate and upload reports
Best Practices
Writing Effective Tests
- Test Behavior, Not Implementation
- Focus on public interface behavior
- Avoid testing internal implementation details
- Test outcomes and side effects
- Clear Test Names
- Use descriptive test names:
TestTokenValidator_ShouldReturnErrorForExpiredToken - Group related tests with subtests
- Document complex test scenarios
- Use descriptive test names:
- Independent Tests
- Each test should be able to run in isolation
- Use setup/teardown for test data
- Avoid test order dependencies
- Minimal Test Data
- Use minimal data required for the test
- Create focused test fixtures
- Use factory functions for complex objects
Test Maintenance
- Keep Tests DRY
- Extract common setup into helper functions
- Use table-driven tests for multiple scenarios
- Share test utilities across packages
- Update Tests with Code Changes
- Update tests when changing interfaces
- Add new tests for new functionality
- Remove tests for deprecated code
- Review Test Coverage Regularly
- Monitor coverage trends in CI
- Identify and test uncovered edge cases
- Remove redundant tests
This testing guide ensures high code quality and maintainability through comprehensive testing practices. All contributors must follow these guidelines to maintain the project’s quality standards.