Skill Improvements: - BDD Testing Skill: Enhanced step templates, debugging guides, and patterns - Gitea Client Skill: Added wiki management, issue tracking, and workflow monitoring - Product Owner Assistant: Improved user story workflow and documentation - Commit Message Skill: Better gitmoji integration and issue referencing - Changelog Manager: Enhanced change tracking and documentation - Skill Creator: Improved skill generation templates and validation - Swagger Documentation: Updated OpenAPI integration guides Key Features: - BDD best practices documentation - Gitea API client with wiki support - User story implementation workflow - Git commit message standardization - Skill development patterns - OpenAPI/Swagger documentation generation Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
600 lines
14 KiB
Markdown
600 lines
14 KiB
Markdown
# 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: "dance-lessons-coach 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. |