Files
dance-lessons-coach/.vibe/skills/bdd-testing/references/TEST_SERVER.md
Gabriel Radureau 89f17cba7d
Some checks failed
CI/CD Pipeline / CI Pipeline (push) Failing after 7m12s
🔧 chore: fix skill naming and gitea actions compatibility (related to #2)
2026-04-06 16:56:11 +02:00

14 KiB

Test Server Implementation Guide

Complete guide to implementing the hybrid in-process test server for BDD testing.

Architecture Overview

Hybrid In-Process Testing

graph TD
    A[BDD Tests] -->|HTTP Requests| B[Test Server]
    B -->|Uses Real Code| C[Actual Server Implementation]
    C -->|Same Process| A

Key Benefits:

  • No external process management
  • Real server behavior
  • Fast execution
  • Reliable startup/shutdown

Implementation

Server Structure

// pkg/bdd/testserver/server.go
type Server struct {
    httpServer  *http.Server
    port       int
    baseURL    string
}

Server Construction

func NewServer() *Server {
    return &Server{
        port: 9191,  // Fixed port for consistency
    }
}

Server Startup

func (s *Server) Start() error {
    s.baseURL = fmt.Sprintf("http://localhost:%d", s.port)
    
    // Create real server instance
    cfg := createTestConfig(s.port)
    realServer := server.NewServer(cfg, context.Background())
    
    // Configure HTTP server
    s.httpServer = &http.Server{
        Addr:    fmt.Sprintf(":%d", s.port),
        Handler: realServer.Router(),  // Use real router!
    }
    
    // Start server in goroutine
    go func() {
        if err := s.httpServer.ListenAndServe(); err != nil {
            if err != http.ErrServerClosed {
                log.Error().Err(err).Msg("Test server failed")
            }
        }
    }()
    
    // Wait for server to be ready
    return s.waitForServerReady()
}

Readiness Verification

func (s *Server) waitForServerReady() error {
    maxAttempts := 30
    
    for attempt := 0; attempt < maxAttempts; attempt++ {
        resp, err := http.Get(fmt.Sprintf("%s/api/ready", s.baseURL))
        
        if err == nil && resp.StatusCode == http.StatusOK {
            resp.Body.Close()
            return nil
        }
        
        if resp != nil {
            resp.Body.Close()
        }
        
        time.Sleep(100 * time.Millisecond)
    }
    
    return fmt.Errorf("server did not become ready after %d attempts", maxAttempts)
}

Graceful Shutdown

func (s *Server) Stop() error {
    if s.httpServer == nil {
        return nil
    }
    
    // Graceful shutdown with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    return s.httpServer.Shutdown(ctx)
}

Configuration

Test Configuration Factory

func createTestConfig(port int) *config.Config {
    return &config.Config{
        Server: config.ServerConfig{
            Host: "localhost",
            Port: port,
        },
        Shutdown: config.ShutdownConfig{
            Timeout: 5 * time.Second,
        },
        Logging: config.LoggingConfig{
            JSON:   false,
            Level:  "trace",
        },
        Telemetry: config.TelemetryConfig{
            Enabled: false,  // Disable telemetry in tests
        },
    }
}

Client Implementation

HTTP Client

// pkg/bdd/testserver/client.go
type Client struct {
    server   *Server
    lastResp *http.Response
    lastBody []byte
}

func NewClient(server *Server) *Client {
    return &Client{
        server: server,
    }
}

Request Method

func (c *Client) Request(method, path string, body []byte) error {
    url := c.server.GetBaseURL() + path
    
    req, err := http.NewRequest(method, url, nil)
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }
    
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()
    
    c.lastResp = resp
    c.lastBody, err = io.ReadAll(resp.Body)
    
    return err
}

Response Validation

func (c *Client) ExpectResponseBody(expected string) error {
    if c.lastResp == nil {
        return fmt.Errorf("no response received")
    }
    
    actual := string(c.lastBody)
    actual = strings.TrimSuffix(actual, "\n")  // Critical: trim newline!
    
    if actual != expected {
        return fmt.Errorf("expected response body %q, got %q", expected, actual)
    }
    
    return nil
}

func (c *Client) ExpectStatusCode(expected int) error {
    if c.lastResp == nil {
        return fmt.Errorf("no response received")
    }
    
    if c.lastResp.StatusCode != expected {
        return fmt.Errorf("expected status %d, got %d", 
            expected, c.lastResp.StatusCode)
    }
    
    return nil
}

Test Suite Integration

Shared Server Pattern

// pkg/bdd/suite.go
var sharedServer *testserver.Server

func InitializeTestSuite(ctx *godog.TestSuiteContext) {
    ctx.BeforeSuite(func() {
        sharedServer = testserver.NewServer()
        if err := sharedServer.Start(); err != nil {
            panic(err)
        }
    })
    
    ctx.AfterSuite(func() {
        if sharedServer != nil {
            sharedServer.Stop()
        }
    })
}

func InitializeScenario(ctx *godog.ScenarioContext) {
    client := testserver.NewClient(sharedServer)
    steps.InitializeAllSteps(ctx, client)
}

Dedicated Server Pattern (for shutdown tests)

func InitializeShutdownTestSuite(ctx *godog.TestSuiteContext) {
    // No shared server for shutdown tests
}

func InitializeShutdownScenario(ctx *godog.ScenarioContext) {
    server := testserver.NewServer()
    client := testserver.NewClient(server)
    
    ctx.BeforeScenario(func(*godog.Scenario) {
        if err := server.Start(); err != nil {
            panic(err)
        }
    })
    
    ctx.AfterScenario(func(*godog.Scenario, error) {
        server.Stop()
    })
    
    shutdown_steps.InitializeShutdownSteps(ctx, client, server)
}

Debugging Techniques

Server Health Checks

# Check if server is running
curl http://localhost:9191/api/ready

# Check health endpoint
curl http://localhost:9191/api/health

# Test greet endpoint
curl http://localhost:9191/api/v1/greet/John

Common Server Issues

Issue Cause Solution
Connection refused Server not started Check BeforeSuite hook
Port already in use Previous test crashed Kill process on port 9191
Server not ready Startup timeout Increase maxAttempts in waitForServerReady
Wrong responses Configuration issue Verify createTestConfig values

Debugging Server Startup

// Add debug logging to waitForServerReady
func (s *Server) waitForServerReady() error {
    for attempt := 0; attempt < 30; attempt++ {
        log.Debug().Int("attempt", attempt+1).Msg("Checking server readiness")
        
        resp, err := http.Get(s.baseURL + "/api/ready")
        
        if err != nil {
            log.Debug().Err(err).Msg("Server not ready yet")
        } else {
            log.Debug().Int("status", resp.StatusCode).Msg("Server responded")
            resp.Body.Close()
            if resp.StatusCode == http.StatusOK {
                log.Info().Msg("Server is ready")
                return nil
            }
        }
        
        time.Sleep(100 * time.Millisecond)
    }
    
    return fmt.Errorf("server never became ready")
}

Performance Optimization

Connection Reuse

// Create reusable HTTP client
var testClient = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        10,
        IdleConnTimeout:     90 * time.Second,
        DisableKeepAlives:   false,
        DisableCompression:  true,
    },
}

// Use in client requests
resp, err := testClient.Do(req)

Parallel Test Execution

// pkg/bdd/bdd_test.go
func TestBDD(t *testing.T) {
    suite := godog.TestSuite{
        Name:                 "DanceLessonsCoach BDD Tests",
        TestSuiteInitializer: bdd.InitializeTestSuite,
        ScenarioInitializer:   bdd.InitializeScenario,
        Options: &godog.Options{
            Format:   "progress",
            Paths:    []string{"."},
            TestingT: t,
            // Enable parallel execution
            Concurrency: 4,  // Number of parallel scenarios
        },
    }
    
    if suite.Run() != 0 {
        t.Fatal("non-zero status returned, failed to run BDD tests")
    }
}

Advanced Patterns

Dynamic Port Allocation

Not recommended for our use case, but possible:

func findFreePort() (int, error) {
    addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
    if err != nil {
        return 0, err
    }
    
    l, err := net.ListenTCP("tcp", addr)
    if err != nil {
        return 0, err
    }
    defer l.Close()
    
    return l.Addr().(*net.TCPAddr).Port, nil
}

Multiple Server Instances

// For testing different configurations
type ServerConfig struct {
    Port    int
    Timeout time.Duration
    Logging bool
}

func NewServerWithConfig(config ServerConfig) *Server {
    return &Server{
        port: config.Port,
        // ...
    }
}

Custom Middleware

// Add test-specific middleware
func (s *Server) Start() error {
    // ... existing setup ...
    
    // Add test middleware
    handler := s.httpServer.Handler
    s.httpServer.Handler = addTestMiddleware(handler)
    
    // ... rest of startup ...
}

func addTestMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Add test headers, logging, etc.
        w.Header().Set("X-Test-Server", "true")
        next.ServeHTTP(w, r)
    })
}

Security Considerations

Test Server Isolation

// Bind to localhost only
func (s *Server) Start() error {
    s.httpServer.Addr = "localhost:9191"  // localhost only!
    // ...
}

Sensitive Data Handling

// Scrub sensitive data from test responses
func scrubSensitiveData(body []byte) []byte {
    // Remove API keys, tokens, etc.
    return bytes.ReplaceAll(body, []byte("api-key-"), []byte("REDACTED-"))
}

Resource Cleanup

// Ensure proper cleanup in AfterSuite
func InitializeTestSuite(ctx *godog.TestSuiteContext) {
    ctx.AfterSuite(func() {
        if sharedServer != nil {
            // Give server time to shutdown gracefully
            ctx := context.Background()
            if err := sharedServer.Stop(); err != nil {
                log.Error().Err(err).Msg("Failed to stop test server")
            }
            
            // Verify server is actually stopped
            for i := 0; i < 5; i++ {
                _, err := http.Get("http://localhost:9191/api/health")
                if err != nil {
                    break  // Server stopped
                }
                time.Sleep(100 * time.Millisecond)
            }
        }
    })
}

Best Practices Summary

DO

  1. Use fixed port (9191) for consistency
  2. Verify server readiness before running tests
  3. Use real server code for realistic testing
  4. Implement graceful shutdown with timeouts
  5. Reuse HTTP connections for better performance
  6. Clean up resources in AfterSuite hooks
  7. Bind to localhost for security
  8. Add debug logging for troubleshooting

DON'T

  1. Don't use external processes (complex management)
  2. Don't mock server responses (defeats black box testing)
  3. Don't share state between scenarios (use fresh clients)
  4. Don't ignore shutdown errors (resource leaks)
  5. Don't use dynamic ports (harder to debug)
  6. Don't expose test server externally (security risk)
  7. Don't forget to clean up (port conflicts)

Troubleshooting Checklist

  1. Server not starting?

    • Check port 9191 is available
    • Verify BeforeSuite hook runs
    • Check server logs for errors
    • Test readiness endpoint manually
  2. Tests timing out?

    • Increase waitForServerReady attempts
    • Check server startup logs
    • Verify database connections (if any)
    • Test with simpler scenarios first
  3. Connection refused?

    • Verify server is running (curl localhost:9191)
    • Check for port conflicts
    • Restart test suite
    • Kill any zombie processes
  4. Wrong responses?

    • Verify test configuration
    • Check real server implementation
    • Test endpoints manually
    • Compare with production behavior

Performance Benchmarks

Our Implementation Results

Metric Value
Server startup time ~100-200ms
Test execution time ~50-100ms per scenario
Memory usage ~50-100MB
Concurrent scenarios 4-8 parallel
Total test suite ~1-2 seconds

Optimization Opportunities

  1. Connection pooling: Reuse HTTP connections
  2. Parallel execution: Run scenarios concurrently
  3. Lazy initialization: Start server only when needed
  4. Caching: Cache configuration and setup
  5. Minimal logging: Reduce log overhead in tests

Integration with Existing Code

Using Real Server Components

// pkg/bdd/testserver/server.go
func (s *Server) Start() error {
    // Use REAL server from pkg/server
    cfg := createTestConfig(s.port)
    realServer := server.NewServer(cfg, context.Background())
    
    // Use real router with all real handlers
    s.httpServer.Handler = realServer.Router()
    
    return s.waitForServerReady()
}

Benefits of Real Server Integration

  1. Realistic testing: Tests actual server behavior
  2. No mocking needed: Uses real handlers and middleware
  3. Catches real bugs: Finds issues that would occur in production
  4. Easy maintenance: Changes to server automatically reflected in tests
  5. Consistent behavior: Tests match production exactly

Future Enhancements

Potential Improvements

  1. Automatic port detection: Find free port if 9191 is taken
  2. Health monitoring: Continuous server health checks
  3. Performance metrics: Track test execution times
  4. Test coverage: Integration with coverage tools
  5. Docker support: Run tests in containers
  6. Configuration options: Make port, timeouts configurable
  1. Dynamic port allocation: Makes debugging harder
  2. External process management: Too complex and unreliable
  3. Mock servers: Defeats black box testing purpose
  4. Global state sharing: Causes test interference

Conclusion

The hybrid in-process test server pattern provides the perfect balance of:

  • Reliability: No external process management issues
  • Realism: Uses actual server code and behavior
  • Performance: Fast startup and execution
  • Debuggability: Fixed port and clear architecture
  • Maintainability: Simple implementation and integration

This approach has proven successful in our BDD implementation and is recommended for all API testing scenarios.