Integration testing
This page covers the different test suites in this repo:
- Go tests (all):
make test(runsgo test ./...) - Go unit-ish tests:
make test-unit(runsgo test -short ./...) - Go HTTP integration tests:
make test-integration(runsgo test ./internal/infrastructure/http/server/...with an in-memory SQLite DB + embedded goose migrations) - Browser E2E tests (Playwright):
cd e2e && npm test(real browser against a real server process + temp, file-backed SQLite DB)
Quick commands
Section titled “Quick commands”make test # all Go testsmake test-unit # fast-ish: `go test -short ./...`make test-integration # HTTP integration tests (SQLite in-memory)Browser E2E tests (Playwright)
Section titled “Browser E2E tests (Playwright)”Prereqs:
- Go
- Node.js
sqlite3available on your PATH (used for DB assertions)
make generatecd e2enpm cinpx playwright install chromiumnpm testLinux CI note: npx playwright install --with-deps chromium.
Go HTTP integration tests
Section titled “Go HTTP integration tests”Run all HTTP integration tests:
make test-integrationSpecific Test Suites
Section titled “Specific Test Suites”Run end-to-end workflow tests:
go test -v -run TestE2E ./internal/infrastructure/http/server/Run API endpoint tests:
go test -v -run TestAPI ./internal/infrastructure/http/server/Run authentication tests:
go test -v -run Auth ./internal/infrastructure/http/server/Run redirect and analytics tests:
go test -v -run Redirect ./internal/infrastructure/http/server/Test Structure
Section titled “Test Structure”End-to-End Tests (e2e_integration_test.go)
Section titled “End-to-End Tests (e2e_integration_test.go)”Comprehensive workflow tests that verify the complete user journey:
TestE2E_FullWorkflow
Section titled “TestE2E_FullWorkflow”Tests the complete workflow:
- Authenticate - Create URL with valid auth token
- Create URL - Generate a shortened URL
- Redirect - Follow the short URL to the original
- Verify Analytics - Confirm click tracking works
What it validates:
- Authentication middleware
- URL creation with database persistence
- HTTP redirects with proper status codes
- Asynchronous click recording
- Analytics data aggregation
TestE2E_AuthenticationFlow
Section titled “TestE2E_AuthenticationFlow”Tests authentication scenarios:
- Valid token acceptance
- Invalid token rejection
- Missing token handling
- Malformed header rejection
What it validates:
- Auth middleware behavior
- Token validation logic
- Error response formats
TestE2E_ErrorScenarios
Section titled “TestE2E_ErrorScenarios”Tests error handling:
- Nonexistent short codes (404)
- Invalid URL formats (400)
- Empty URLs (400)
- Malformed JSON (400)
- Analytics for missing URLs (404)
What it validates:
- Input validation
- Error responses
- HTTP status codes
- Error message formats
TestE2E_APIEndpoints
Section titled “TestE2E_APIEndpoints”Tests all API endpoints:
- POST /api/urls - Create URL
- GET /api/urls - List URLs
- GET /api/urls/{shortCode}/analytics - Get analytics
- DELETE /api/urls/{shortCode} - Delete URL
What it validates:
- Complete API surface
- Request/response formats
- CRUD operations
- Data persistence
TestE2E_MultipleClicks
Section titled “TestE2E_MultipleClicks”Tests analytics with multiple redirects:
- Multiple clicks from different referrers
- Async click recording
- Analytics aggregation
What it validates:
- Click tracking accuracy
- Referrer tracking
- Async worker queue
- Analytics computation
TestE2E_ConcurrentCreation
Section titled “TestE2E_ConcurrentCreation”Tests concurrent URL creation:
- 10 simultaneous URL creation requests
- Database concurrency handling
What it validates:
- Thread safety
- Database locking
- Race condition handling
TestE2E_HealthCheck
Section titled “TestE2E_HealthCheck”Tests health check endpoint:
- Successful response
- No authentication required
What it validates:
- Monitoring endpoints
- Public access routes
Other Integration Tests
Section titled “Other Integration Tests”API Integration Tests (api_integration_test.go)
Section titled “API Integration Tests (api_integration_test.go)”- Detailed API endpoint testing
- Request validation
- Response format verification
Auth Integration Tests (auth_integration_test.go)
Section titled “Auth Integration Tests (auth_integration_test.go)”- Authentication middleware
- Protected vs public routes
- User context propagation
Redirect Integration Tests (redirect_integration_test.go)
Section titled “Redirect Integration Tests (redirect_integration_test.go)”- URL redirection logic
- Click tracking
- Analytics recording
Analytics Integration Tests (analytics_integration_test.go)
Section titled “Analytics Integration Tests (analytics_integration_test.go)”- Analytics computation
- Time range filtering
- Data aggregation
Server Integration Tests (integration_test.go)
Section titled “Server Integration Tests (integration_test.go)”- Middleware execution order
- CORS handling
- Concurrent requests
- Graceful shutdown
Test Database Setup
Section titled “Test Database Setup”All integration tests use in-memory SQLite databases for isolation and speed:
db := setupTestDB(t)defer db.Close()The setupTestDB helper:
- Creates an in-memory SQLite database (
:memory:) - Applies all migrations using goose
- Returns a ready-to-use database connection
Benefits:
- Fast - No disk I/O
- Isolated - Each test gets a fresh database
- Portable - No external dependencies
- Deterministic - Clean state for every test
Test Patterns
Section titled “Test Patterns”Table-Driven Tests
Section titled “Table-Driven Tests”Most tests use table-driven patterns for comprehensive scenario coverage:
tests := []struct { name string authHeader string expectedStatus int checkResponse func(t *testing.T, body map[string]interface{})}{ { name: "valid_token", authHeader: "Bearer test-token", expectedStatus: http.StatusCreated, }, // More test cases...}
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test logic })}Subtests
Section titled “Subtests”Related scenarios are grouped as subtests:
t.Run("create_url", func(t *testing.T) { // Create URL test
t.Run("redirect", func(t *testing.T) { // Redirect test using created URL })})Helper Functions
Section titled “Helper Functions”Common test utilities:
setupTestDB(t)- Create test databasetestLogger()- Get a disabled logger for tests- Standard Go testing helpers
Best Practices
Section titled “Best Practices”Test Isolation
Section titled “Test Isolation”- Each test creates its own database
- No shared state between tests
- Independent test execution
Async Operations
Section titled “Async Operations”For async operations like click recording, use polling with a deadline instead of a fixed sleep:
// Trigger redirectsrv.router.ServeHTTP(rec, req)
// Wait for async processing using polling with a deadlinedeadline := time.Now().Add(2 * time.Second)
for { // Check condition (e.g., click recorded in analytics) analyticsReq := httptest.NewRequest(http.MethodGet, "/api/urls/"+shortCode+"/analytics", nil) analyticsReq.Header.Set("Authorization", "Bearer test-token")
analyticsRec := httptest.NewRecorder() srv.router.ServeHTTP(analyticsRec, analyticsReq)
if analyticsRec.Code == http.StatusOK { var analytics map[string]interface{} if err := json.Unmarshal(analyticsRec.Body.Bytes(), &analytics); err == nil { if totalClicks, ok := analytics["total_clicks"].(float64); ok && totalClicks > 0 { break } } }
if time.Now().After(deadline) { t.Fatalf("timeout waiting for click to be recorded") }
time.Sleep(10 * time.Millisecond)}Concurrent Tests
Section titled “Concurrent Tests”Use proper synchronization:
results := make(chan error, numRequests)for i := 0; i < numRequests; i++ { go func(index int) { // Test logic results <- nil }(i)}
// Collect resultsfor i := 0; i < numRequests; i++ { if err := <-results; err != nil { t.Error(err) }}Error Messages
Section titled “Error Messages”Provide context in error messages:
if rec.Code != tt.expectedStatus { t.Errorf("expected status %d, got %d. Body: %s", tt.expectedStatus, rec.Code, rec.Body.String())}CI/CD Integration
Section titled “CI/CD Integration”CI runs on GitHub Actions (see .github/workflows/ci.yml) and executes go test ./... (plus separate formatting/vet/lint and generated-code checks).
Integration tests live under internal/infrastructure/http/server/ and can be run locally with:
make test-integrationDocker Compose Testing
Section titled “Docker Compose Testing”Docker Compose is intended for manual local testing with a persistent SQLite database (bind-mounted to ./data). See Docker Compose testing.
Coverage
Section titled “Coverage”To get a coverage report for the HTTP integration suite:
go test -cover ./internal/infrastructure/http/server/Detailed coverage:
go test -coverprofile=coverage.out ./internal/infrastructure/http/server/go tool cover -html=coverage.outTroubleshooting
Section titled “Troubleshooting”Tests Fail with “database is locked”
Section titled “Tests Fail with “database is locked””Cause: SQLite doesn’t handle high concurrency well in tests. Fix: Reduce concurrent operations or add delays between operations.
Async tests are flaky
Section titled “Async tests are flaky”Cause: Not waiting long enough for async operations. Fix: Prefer polling with a deadline (the suite already uses this pattern); increase the polling deadline if needed.
Port already in use
Section titled “Port already in use”Cause: Tests using real HTTP server instead of httptest.
Fix: Use httptest.NewRecorder() instead of real HTTP server.
Adding New Integration Tests
Section titled “Adding New Integration Tests”1. Create Test File
Section titled “1. Create Test File”Place in internal/infrastructure/http/server/:
package server
import ( "testing" // imports...)2. Follow Naming Convention
Section titled “2. Follow Naming Convention”- File:
*_integration_test.goor*_test.go - Function:
TestE2E_*orTest*Integration
3. Use Test Helpers
Section titled “3. Use Test Helpers”func TestYourFeature(t *testing.T) { db := setupTestDB(t) defer db.Close()
cfg := &config.Config{ ServerPort: 8080, BaseURL: "http://localhost:8080", DatabaseURL: "test.db", AuthToken: "test-token", AllowedOrigins: "*", }
srv, err := New(cfg, db, testLogger()) if err != nil { t.Fatalf("failed to create server: %v", err) } defer srv.Shutdown(context.Background())
// Test logic here}4. Use httptest for HTTP Testing
Section titled “4. Use httptest for HTTP Testing”req := httptest.NewRequest(http.MethodPost, "/api/urls", body)req.Header.Set("Authorization", "Bearer test-token")rec := httptest.NewRecorder()
srv.router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated { t.Errorf("expected %d, got %d", http.StatusCreated, rec.Code)}5. Clean Up Resources
Section titled “5. Clean Up Resources”Always use defer for cleanup:
db := setupTestDB(t)defer db.Close()
srv, _ := New(cfg, db, logger)defer srv.Shutdown(context.Background())Performance Benchmarks
Section titled “Performance Benchmarks”Integration tests include benchmarks:
# Run benchmarksgo test -bench=. ./internal/infrastructure/http/server/
# With memory profilinggo test -bench=. -benchmem ./internal/infrastructure/http/server/Example benchmark results:
BenchmarkServer_HealthCheck-8 500000 2314 ns/opBenchmarkServer_WithMiddleware-8 200000 8745 ns/opRelated Documentation
Section titled “Related Documentation”- Docker Compose testing - Docker Compose testing guide
- README.md - Main project documentation
- Contributing - Contribution guidelines
Summary
Section titled “Summary”The integration testing suite provides:
✅ Comprehensive Coverage - All API endpoints and workflows tested ✅ Fast Execution - In-memory database, ~1 second total ✅ Reliable - Designed to minimize flakiness with deterministic assertions (some tests use time-based polling and may be timing-sensitive on heavily loaded environments) ✅ CI/CD Ready - No external dependencies required ✅ Well Organized - Clear test structure and naming ✅ Documented - Examples and patterns for new tests ✅ Maintainable - Helper functions and consistent patterns
The tests serve as both quality assurance and living documentation of system behavior.