Skip to content

Testing Guide

This guide covers testing patterns and strategies used in Olla.

Testing Philosophy

  • Test behaviour, not implementation
  • Fast, reliable, and isolated tests
  • Comprehensive coverage of critical paths
  • Shared test suites for common interfaces

Test Organisation

.
├── internal/
│   └── */                  # Unit tests alongside code
│       └── *_test.go
├── test/
│   ├── integration/        # Integration tests
│   ├── scripts/           # Test scripts
│   └── fixtures/          # Test data
└── benchmarks/            # Performance benchmarks

Unit Testing

Basic Test Structure

func TestEndpointHealth(t *testing.T) {
    // Arrange
    endpoint := &Endpoint{
        URL: "http://localhost:8080",
        Health: StatusHealthy,
    }

    // Act
    result := endpoint.IsHealthy()

    // Assert
    if !result {
        t.Errorf("expected healthy endpoint, got unhealthy")
    }
}

Table-Driven Tests

func TestEndpointSelection(t *testing.T) {
    tests := []struct {
        name      string
        endpoints []*Endpoint
        expected  string
        wantErr   bool
    }{
        {
            name: "selects highest priority",
            endpoints: []*Endpoint{
                {URL: "http://a", Priority: 50},
                {URL: "http://b", Priority: 100},
            },
            expected: "http://b",
            wantErr:  false,
        },
        {
            name:      "returns error when no endpoints",
            endpoints: []*Endpoint{},
            expected:  "",
            wantErr:   true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            selector := NewPrioritySelector()
            result, err := selector.Select(tt.endpoints)

            if (err != nil) != tt.wantErr {
                t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
                return
            }

            if result != nil && result.URL != tt.expected {
                t.Errorf("got %v, want %v", result.URL, tt.expected)
            }
        })
    }
}

Testing Concurrent Code

func TestConcurrentStats(t *testing.T) {
    stats := NewStats()

    var wg sync.WaitGroup
    workers := 100
    iterations := 1000

    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < iterations; j++ {
                stats.IncrementRequests()
            }
        }()
    }

    wg.Wait()

    expected := workers * iterations
    if got := stats.GetRequestCount(); got != expected {
        t.Errorf("expected %d requests, got %d", expected, got)
    }
}

Testing with Context

func TestRequestWithTimeout(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    service := NewService()

    // Simulate slow operation
    err := service.SlowOperation(ctx)

    if !errors.Is(err, context.DeadlineExceeded) {
        t.Errorf("expected timeout error, got %v", err)
    }
}

Integration Testing

Shared Test Suites

Both proxy engines share test suites:

// internal/adapter/proxy/shared_test.go
func runProxyTests(t *testing.T, factory ProxyFactory) {
    t.Run("forwards request", func(t *testing.T) {
        testForwardsRequest(t, factory)
    })

    t.Run("handles streaming", func(t *testing.T) {
        testHandlesStreaming(t, factory)
    })

    t.Run("circuit breaker", func(t *testing.T) {
        testCircuitBreaker(t, factory)
    })
}

// internal/adapter/proxy/sherpa/service_test.go
func TestSherpaProxy(t *testing.T) {
    factory := NewSherpaFactory()
    runProxyTests(t, factory)
}

// internal/adapter/proxy/olla/service_test.go
func TestOllaProxy(t *testing.T) {
    factory := NewOllaFactory()
    runProxyTests(t, factory)
}

Test Servers

func TestProxyForwarding(t *testing.T) {
    // Create test backend
    backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("test response"))
    }))
    defer backend.Close()

    // Configure proxy
    proxy := NewProxy([]Endpoint{{URL: backend.URL}})

    // Create test request
    req := httptest.NewRequest("GET", "/test", nil)
    rec := httptest.NewRecorder()

    // Test
    err := proxy.ProxyRequest(context.Background(), rec, req)
    if err != nil {
        t.Fatalf("proxy failed: %v", err)
    }

    // Verify
    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }

    if body := rec.Body.String(); body != "test response" {
        t.Errorf("expected 'test response', got %s", body)
    }
}

Testing Streaming

func TestStreamingResponse(t *testing.T) {
    // Create streaming backend
    backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        flusher := w.(http.Flusher)

        for i := 0; i < 3; i++ {
            fmt.Fprintf(w, "chunk %d\n", i)
            flusher.Flush()
            time.Sleep(10 * time.Millisecond)
        }
    }))
    defer backend.Close()

    proxy := NewProxy([]Endpoint{{URL: backend.URL}})

    req := httptest.NewRequest("GET", "/stream", nil)
    rec := httptest.NewRecorder()

    err := proxy.ProxyRequest(context.Background(), rec, req)
    if err != nil {
        t.Fatalf("streaming failed: %v", err)
    }

    expected := "chunk 0\nchunk 1\nchunk 2\n"
    if got := rec.Body.String(); got != expected {
        t.Errorf("expected %q, got %q", expected, got)
    }
}

Mocking

Interface Mocking

type MockDiscoveryService struct {
    endpoints []*Endpoint
    err       error
}

func (m *MockDiscoveryService) GetHealthyEndpoints(ctx context.Context) ([]*Endpoint, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.endpoints, nil
}

func TestProxyWithMockDiscovery(t *testing.T) {
    mock := &MockDiscoveryService{
        endpoints: []*Endpoint{
            {URL: "http://test", Health: StatusHealthy},
        },
    }

    proxy := NewProxy(mock)

    // Test with mock
    endpoints, err := proxy.GetAvailableEndpoints(context.Background())
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if len(endpoints) != 1 {
        t.Errorf("expected 1 endpoint, got %d", len(endpoints))
    }
}

HTTP Client Mocking

type MockHTTPClient struct {
    response *http.Response
    err      error
}

func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.response, nil
}

func TestHealthCheckWithMock(t *testing.T) {
    client := &MockHTTPClient{
        response: &http.Response{
            StatusCode: http.StatusOK,
            Body:       io.NopCloser(strings.NewReader("healthy")),
        },
    }

    checker := NewHealthChecker(client)
    healthy := checker.Check("http://test")

    if !healthy {
        t.Error("expected healthy, got unhealthy")
    }
}

Test Helpers

Common Test Utilities

// test/helpers/endpoints.go
func GenerateEndpoints(count int) []*Endpoint {
    endpoints := make([]*Endpoint, count)
    for i := 0; i < count; i++ {
        endpoints[i] = &Endpoint{
            URL:      fmt.Sprintf("http://endpoint-%d", i),
            Priority: rand.Intn(100),
            Health:   StatusHealthy,
        }
    }
    return endpoints
}

// test/helpers/context.go
func ContextWithTimeout(t *testing.T, timeout time.Duration) context.Context {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    t.Cleanup(cancel)
    return ctx
}

Test Fixtures

// test/fixtures/config.go
func LoadTestConfig(t *testing.T) *Config {
    data, err := os.ReadFile("testdata/config.yaml")
    if err != nil {
        t.Fatalf("failed to load test config: %v", err)
    }

    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        t.Fatalf("failed to parse test config: %v", err)
    }

    return &cfg
}

Race Detection

Always test concurrent code with race detection:

go test -race ./...

Example race-safe test:

func TestRaceSafety(t *testing.T) {
    service := NewService()

    // Run concurrent operations
    go service.Write("data1")
    go service.Write("data2")
    go service.Read()

    // Give goroutines time to execute
    time.Sleep(100 * time.Millisecond)
}

Coverage

Generate Coverage Report

# Run with coverage
go test -cover -coverprofile=coverage.out ./...

# View in terminal
go tool cover -func=coverage.out

# Generate HTML report
go tool cover -html=coverage.out -o coverage.html

Coverage Goals

  • Critical paths: 90%+ coverage
  • Business logic: 80%+ coverage
  • Overall: 70%+ coverage

Test Performance

Keep Tests Fast

// Bad - slow test
func TestSlowOperation(t *testing.T) {
    time.Sleep(5 * time.Second) // Don't do this
    // ...
}

// Good - use time manipulation
func TestWithMockTime(t *testing.T) {
    clock := &MockClock{now: time.Now()}
    service := NewServiceWithClock(clock)

    // Advance time instantly
    clock.Advance(5 * time.Second)

    // Test timeout behaviour
    if !service.IsTimedOut() {
        t.Error("expected timeout")
    }
}

Parallel Tests

func TestParallel(t *testing.T) {
    t.Parallel() // Run in parallel with other tests

    // Test code here
}

func TestSubtests(t *testing.T) {
    t.Run("subtest1", func(t *testing.T) {
        t.Parallel() // Subtests can also be parallel
        // Test code
    })

    t.Run("subtest2", func(t *testing.T) {
        t.Parallel()
        // Test code
    })
}

CI/CD Integration

GitHub Actions

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-go@v4
        with:
          go-version: '1.24'

      - name: Run tests
        run: make test

      - name: Race detection
        run: go test -race ./...

      - name: Coverage
        run: |
          go test -cover -coverprofile=coverage.out ./...
          go tool cover -func=coverage.out

Best Practices

1. Test Independence

Tests should not depend on each other:

// Bad - depends on order
var sharedState int

func TestFirst(t *testing.T) {
    sharedState = 42
}

func TestSecond(t *testing.T) {
    if sharedState != 42 { // Fails if TestFirst didn't run
        t.Error("unexpected state")
    }
}

// Good - independent
func TestIndependent(t *testing.T) {
    state := setupState()
    defer cleanupState(state)

    // Test with local state
}

2. Clear Test Names

// Bad
func TestProxy1(t *testing.T) {}
func TestProxy2(t *testing.T) {}

// Good
func TestProxy_ForwardsRequest_Success(t *testing.T) {}
func TestProxy_CircuitBreaker_OpensAfterFailures(t *testing.T) {}

3. Cleanup Resources

func TestWithCleanup(t *testing.T) {
    resource := acquireResource()
    t.Cleanup(func() {
        releaseResource(resource)
    })

    // Test code
}

Next Steps