🔧 chore: fix skill naming and gitea actions compatibility (related to #2)
Some checks failed
CI/CD Pipeline / CI Pipeline (push) Failing after 7m12s
Some checks failed
CI/CD Pipeline / CI Pipeline (push) Failing after 7m12s
This commit is contained in:
600
.vibe/skills/bdd-testing/references/TEST_SERVER.md
Normal file
600
.vibe/skills/bdd-testing/references/TEST_SERVER.md
Normal file
@@ -0,0 +1,600 @@
|
||||
# Test Server Implementation Guide
|
||||
|
||||
Complete guide to implementing the hybrid in-process test server for BDD testing.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Hybrid In-Process Testing
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```go
|
||||
// pkg/bdd/testserver/server.go
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
port int
|
||||
baseURL string
|
||||
}
|
||||
```
|
||||
|
||||
### Server Construction
|
||||
|
||||
```go
|
||||
func NewServer() *Server {
|
||||
return &Server{
|
||||
port: 9191, // Fixed port for consistency
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Server Startup
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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)
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// Bind to localhost only
|
||||
func (s *Server) Start() error {
|
||||
s.httpServer.Addr = "localhost:9191" // localhost only!
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive Data Handling
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
### Not Recommended
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user