Testing Gin Applications

Learning Objectives: By the end of this lesson, you will be able to:

  • Write comprehensive unit tests for Gin handlers and middleware
  • Implement integration tests for complete API endpoints
  • Create effective test doubles (mocks, stubs, fakes) for dependencies
  • Set up test databases and manage test data
  • Measure and improve test coverage
  • Implement end-to-end testing for complete user workflows
  • Apply testing best practices and patterns for maintainable test suites
  • Debug and troubleshoot failing tests effectively

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Have you ever deployed code that worked perfectly on your machine but broke in production? Or spent hours debugging an issue that could have been caught with a simple test? Testing is often viewed as tedious, but it’s actually one of the most powerful tools for building reliable, maintainable applications. Today, we’ll transform your approach to testing from a chore into a superpower that makes development faster and more confident.

Lesson Overview

  • Duration: 135 minutes
  • Prerequisites: Lessons 1-7 completed, basic Go testing knowledge, understanding of HTTP concepts
  • Materials Needed: Go 1.25+ for Gin v1.12, Gin framework, testing libraries (testify, httptest)

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. Testing Fundamentals - Test types, patterns, and Go testing basics
  2. Unit Testing - Testing handlers, middleware, and business logic in isolation
  3. Integration Testing - Testing complete request-response cycles
  4. Test Doubles - Mocking dependencies for reliable, fast tests
  5. Database Testing - Managing test data and database states
  6. Advanced Testing - Coverage analysis, benchmarking, and E2E testing
  7. Testing Patterns - Best practices and common testing scenarios

Success Criteria:

By the end of this session, you should be able to build a comprehensive test suite that gives you confidence to refactor, deploy, and maintain your Gin applications.

3. Foundation Knowledge (15-20 minutes)

Core Concepts

Testing Pyramid

The testing pyramid guides how to distribute your testing efforts:

  1. Unit Tests (70%): Fast, isolated, test individual components
  2. Integration Tests (20%): Test component interactions
  3. End-to-End Tests (10%): Test complete user workflows

Real-World Analogy: Think of building a house - unit tests are like checking each brick, integration tests verify walls hold together, and E2E tests ensure the whole house is liveable.

Why This Matters: Following the pyramid ensures fast feedback cycles while catching issues at the appropriate level, reducing debugging time and increasing development velocity.

Test Types in Web Applications

Unit Tests:

  • Test individual functions/methods in isolation
  • Mock external dependencies
  • Fast execution (milliseconds)
  • High coverage, specific failure identification

Integration Tests:

  • Test multiple components working together
  • Use real or test databases
  • Medium execution time (seconds)
  • Verify component contracts

End-to-End Tests:

  • Test complete user scenarios
  • Use real systems or staging environments
  • Slow execution (minutes)
  • Verify business requirements

Test-Driven Development (TDD)

TDD follows the Red-Green-Refactor cycle:

  1. Red: Write a failing test
  2. Green: Write minimal code to pass
  3. Refactor: Improve code while keeping tests passing

Behavior-Driven Development (BDD)

BDD focuses on business behavior:

  • Given (initial state)
  • When (action taken)
  • Then (expected outcome)

Key Terminology

  • Test Double: Generic term for any object that replaces a real dependency in tests
  • Mock: Records and verifies interactions with dependencies
  • Stub: Returns predefined responses without verification
  • Fake: Simplified implementation that works but isn’t production-ready
  • Test Coverage: Percentage of code executed during tests
  • Test Fixture: Fixed state of data used as baseline for tests
  • Assertion: Statement that verifies expected behavior

4. Detailed Explanation and Examples (25-30 minutes)

Understanding Testing in Go

Go’s built-in testing package provides a solid foundation:

  • Test files end with _test.go
  • Test functions start with Test and take *testing.T
  • Benchmark functions start with Benchmark and take *testing.B
  • Use go test command to run tests

HTTP Testing with httptest Package

Go’s httptest package provides tools for testing HTTP handlers:

  • httptest.NewRecorder(): Records HTTP responses
  • httptest.NewServer(): Creates test HTTP servers
  • httptest.NewRequest(): Creates HTTP requests for testing

Gin Testing Patterns

Gin applications can be tested in multiple ways:

  1. Unit testing handlers: Test handler functions directly
  2. Router testing: Test routes with Gin’s test mode
  3. Middleware testing: Test middleware functions in isolation
  4. Full integration: Test complete request-response cycles

Common Testing Challenges

State Management

Web applications often have state that affects tests:

  • Database records
  • Cache entries
  • Session data
  • External service states

Solution: Use test fixtures, database transactions, or test containers to manage state.

Time-Dependent Logic

Code that depends on current time can be difficult to test:

  • Expiration checks
  • Rate limiting
  • Audit timestamps

Solution: Inject time dependencies or use time mocking libraries.

External Dependencies

Real external services in tests cause problems:

  • Slow test execution
  • Unreliable network conditions
  • Rate limiting
  • Test data pollution

Solution: Use test doubles to replace external dependencies.

Common Misconceptions

  1. Misconception: “100% test coverage means no bugs” Reality: Coverage measures what code runs, not whether it’s correct. Quality of tests matters more than quantity.

  2. Misconception: “Mocking everything makes tests better” Reality: Over-mocking can make tests brittle and less valuable. Mock judiciously, especially at architectural boundaries.

  3. Misconception: “Tests should never duplicate implementation logic” Reality: Some duplication in tests is acceptable and can catch refactoring errors. Tests should verify behavior, not implementation.

5. Live Coding Demonstration (20-25 minutes)

Example 1: Unit Testing Gin Handlers

// handlers/user_handler.go
package handlers

import (
    "net/http"
    "strconv"
    
    "github.com/gin-gonic/gin"
    "github.com/your-app/models"
    "github.com/your-app/services"
)

// UserHandler handles user-related requests
type UserHandler struct {
    userService services.UserService
}

// NewUserHandler creates a new user handler
func NewUserHandler(userService services.UserService) *UserHandler {
    return &UserHandler{
        userService: userService,
    }
}

// CreateUser creates a new user
func (h *UserHandler) CreateUser(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error":   "Invalid input",
            "details": err.Error(),
        })
        return
    }
    
    // Validate input
    if err := req.Validate(); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error":   "Validation failed",
            "details": err.Error(),
        })
        return
    }
    
    // Create user through service
    user, err := h.userService.CreateUser(req.Username, req.Email, req.FullName)
    if err != nil {
        if err == services.ErrUserExists {
            c.JSON(http.StatusConflict, gin.H{
                "error": "User already exists",
            })
            return
        }
        
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to create user",
        })
        return
    }
    
    c.JSON(http.StatusCreated, gin.H{
        "message": "User created successfully",
        "user":    user,
    })
}

// GetUser retrieves a user by ID
func (h *UserHandler) GetUser(c *gin.Context) {
    idStr := c.Param("id")
    id, err := strconv.ParseUint(idStr, 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid user ID",
        })
        return
    }
    
    user, err := h.userService.GetUserByID(uint(id))
    if err != nil {
        if err == services.ErrUserNotFound {
            c.JSON(http.StatusNotFound, gin.H{
                "error": "User not found",
            })
            return
        }
        
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to retrieve user",
        })
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "user": user,
    })
}

// UpdateUser updates an existing user
func (h *UserHandler) UpdateUser(c *gin.Context) {
    idStr := c.Param("id")
    id, err := strconv.ParseUint(idStr, 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid user ID",
        })
        return
    }
    
    var req UpdateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error":   "Invalid input",
            "details": err.Error(),
        })
        return
    }
    
    user, err := h.userService.UpdateUser(uint(id), req.Email, req.FullName)
    if err != nil {
        if err == services.ErrUserNotFound {
            c.JSON(http.StatusNotFound, gin.H{
                "error": "User not found",
            })
            return
        }
        
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to update user",
        })
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "message": "User updated successfully",
        "user":    user,
    })
}

// CreateUserRequest represents create user request
type CreateUserRequest struct {
    Username string `json:"username" binding:"required,min=3,max=30,alphanum"`
    Email    string `json:"email" binding:"required,email"`
    FullName string `json:"full_name" binding:"required,min=2,max=100"`
}

// Validate validates the create user request
func (r *CreateUserRequest) Validate() error {
    // Additional validation logic
    if len(strings.TrimSpace(r.Username)) == 0 {
        return errors.New("username cannot be empty")
    }
    return nil
}

// UpdateUserRequest represents update user request
type UpdateUserRequest struct {
    Email    string `json:"email" binding:"required,email"`
    FullName string `json:"full_name" binding:"required,min=2,max=100"`
}

// handlers/user_handler_test.go
package handlers

import (
    "bytes"
    "encoding/json"
    "errors"
    "net/http"
    "net/http/httptest"
    "testing"
    
    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "github.com/your-app/models"
    "github.com/your-app/services"
    "github.com/your-app/services/mocks"
)

// Setup test environment
func setupTestRouter() *gin.Engine {
    gin.SetMode(gin.TestMode)
    return gin.New()
}

func TestUserHandler_CreateUser(t *testing.T) {
    tests := []struct {
        name           string
        requestBody    interface{}
        setupMock      func(*mocks.UserService)
        expectedStatus int
        expectedBody   string
        checkResponse  func(*testing.T, *httptest.ResponseRecorder)
    }{
        {
            name: "successful user creation",
            requestBody: CreateUserRequest{
                Username: "testuser",
                Email:    "test@example.com",
                FullName: "Test User",
            },
            setupMock: func(mockService *mocks.UserService) {
                expectedUser := &models.User{
                    ID:       1,
                    Username: "testuser",
                    Email:    "test@example.com",
                    FullName: "Test User",
                }
                mockService.On("CreateUser", "testuser", "test@example.com", "Test User").
                    Return(expectedUser, nil)
            },
            expectedStatus: http.StatusCreated,
            checkResponse: func(t *testing.T, w *httptest.ResponseRecorder) {
                var response map[string]interface{}
                err := json.Unmarshal(w.Body.Bytes(), &response)
                assert.NoError(t, err)
                assert.Equal(t, "User created successfully", response["message"])
                assert.NotNil(t, response["user"])
                
                user := response["user"].(map[string]interface{})
                assert.Equal(t, "testuser", user["username"])
                assert.Equal(t, "test@example.com", user["email"])
            },
        },
        {
            name: "invalid request body",
            requestBody: map[string]interface{}{
                "username": "",
                "email":    "invalid-email",
            },
            setupMock: func(mockService *mocks.UserService) {
                // No mock setup needed as validation fails first
            },
            expectedStatus: http.StatusBadRequest,
            checkResponse: func(t *testing.T, w *httptest.ResponseRecorder) {
                var response map[string]interface{}
                err := json.Unmarshal(w.Body.Bytes(), &response)
                assert.NoError(t, err)
                assert.Equal(t, "Invalid input", response["error"])
                assert.NotNil(t, response["details"])
            },
        },
        {
            name: "user already exists",
            requestBody: CreateUserRequest{
                Username: "existinguser",
                Email:    "existing@example.com",
                FullName: "Existing User",
            },
            setupMock: func(mockService *mocks.UserService) {
                mockService.On("CreateUser", "existinguser", "existing@example.com", "Existing User").
                    Return(nil, services.ErrUserExists)
            },
            expectedStatus: http.StatusConflict,
            checkResponse: func(t *testing.T, w *httptest.ResponseRecorder) {
                var response map[string]interface{}
                err := json.Unmarshal(w.Body.Bytes(), &response)
                assert.NoError(t, err)
                assert.Equal(t, "User already exists", response["error"])
            },
        },
        {
            name: "service error",
            requestBody: CreateUserRequest{
                Username: "testuser",
                Email:    "test@example.com",
                FullName: "Test User",
            },
            setupMock: func(mockService *mocks.UserService) {
                mockService.On("CreateUser", "testuser", "test@example.com", "Test User").
                    Return(nil, errors.New("database connection failed"))
            },
            expectedStatus: http.StatusInternalServerError,
            checkResponse: func(t *testing.T, w *httptest.ResponseRecorder) {
                var response map[string]interface{}
                err := json.Unmarshal(w.Body.Bytes(), &response)
                assert.NoError(t, err)
                assert.Equal(t, "Failed to create user", response["error"])
            },
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Setup
            mockService := new(mocks.UserService)
            tt.setupMock(mockService)
            
            handler := NewUserHandler(mockService)
            router := setupTestRouter()
            router.POST("/users", handler.CreateUser)
            
            // Create request
            body, _ := json.Marshal(tt.requestBody)
            req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body))
            req.Header.Set("Content-Type", "application/json")
            
            // Execute
            w := httptest.NewRecorder()
            router.ServeHTTP(w, req)
            
            // Assert
            assert.Equal(t, tt.expectedStatus, w.Code)
            if tt.checkResponse != nil {
                tt.checkResponse(t, w)
            }
            
            // Verify mock expectations
            mockService.AssertExpectations(t)
        })
    }
}

func TestUserHandler_GetUser(t *testing.T) {
    tests := []struct {
        name           string
        userID         string
        setupMock      func(*mocks.UserService)
        expectedStatus int
        checkResponse  func(*testing.T, *httptest.ResponseRecorder)
    }{
        {
            name:   "successful user retrieval",
            userID: "1",
            setupMock: func(mockService *mocks.UserService) {
                expectedUser := &models.User{
                    ID:       1,
                    Username: "testuser",
                    Email:    "test@example.com",
                    FullName: "Test User",
                }
                mockService.On("GetUserByID", uint(1)).Return(expectedUser, nil)
            },
            expectedStatus: http.StatusOK,
            checkResponse: func(t *testing.T, w *httptest.ResponseRecorder) {
                var response map[string]interface{}
                err := json.Unmarshal(w.Body.Bytes(), &response)
                assert.NoError(t, err)
                
                user := response["user"].(map[string]interface{})
                assert.Equal(t, "testuser", user["username"])
                assert.Equal(t, "test@example.com", user["email"])
            },
        },
        {
            name:   "invalid user ID",
            userID: "invalid",
            setupMock: func(mockService *mocks.UserService) {
                // No mock setup needed as validation fails first
            },
            expectedStatus: http.StatusBadRequest,
            checkResponse: func(t *testing.T, w *httptest.ResponseRecorder) {
                var response map[string]interface{}
                err := json.Unmarshal(w.Body.Bytes(), &response)
                assert.NoError(t, err)
                assert.Equal(t, "Invalid user ID", response["error"])
            },
        },
        {
            name:   "user not found",
            userID: "999",
            setupMock: func(mockService *mocks.UserService) {
                mockService.On("GetUserByID", uint(999)).Return(nil, services.ErrUserNotFound)
            },
            expectedStatus: http.StatusNotFound,
            checkResponse: func(t *testing.T, w *httptest.ResponseRecorder) {
                var response map[string]interface{}
                err := json.Unmarshal(w.Body.Bytes(), &response)
                assert.NoError(t, err)
                assert.Equal(t, "User not found", response["error"])
            },
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Setup
            mockService := new(mocks.UserService)
            tt.setupMock(mockService)
            
            handler := NewUserHandler(mockService)
            router := setupTestRouter()
            router.GET("/users/:id", handler.GetUser)
            
            // Execute
            req, _ := http.NewRequest("GET", "/users/"+tt.userID, nil)
            w := httptest.NewRecorder()
            router.ServeHTTP(w, req)
            
            // Assert
            assert.Equal(t, tt.expectedStatus, w.Code)
            if tt.checkResponse != nil {
                tt.checkResponse(t, w)
            }
            
            // Verify mock expectations
            mockService.AssertExpectations(t)
        })
    }
}

// Benchmark test example
func BenchmarkUserHandler_CreateUser(b *testing.B) {
    mockService := new(mocks.UserService)
    expectedUser := &models.User{
        ID:       1,
        Username: "testuser",
        Email:    "test@example.com",
        FullName: "Test User",
    }
    mockService.On("CreateUser", mock.Anything, mock.Anything, mock.Anything).
        Return(expectedUser, nil)
    
    handler := NewUserHandler(mockService)
    router := setupTestRouter()
    router.POST("/users", handler.CreateUser)
    
    requestBody := CreateUserRequest{
        Username: "testuser",
        Email:    "test@example.com",
        FullName: "Test User",
    }
    body, _ := json.Marshal(requestBody)
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body))
        req.Header.Set("Content-Type", "application/json")
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
    }
}

Example 2: Integration Testing with Test Database

// tests/integration/user_integration_test.go
package integration

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "os"
    "testing"
    
    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/suite"
    "gorm.io/gorm"
    "github.com/your-app/config"
    "github.com/your-app/database"
    "github.com/your-app/handlers"
    "github.com/your-app/models"
    "github.com/your-app/repositories"
    "github.com/your-app/services"
)

// UserIntegrationTestSuite contains integration tests for user functionality
type UserIntegrationTestSuite struct {
    suite.Suite
    db     *gorm.DB
    router *gin.Engine
    server *httptest.Server
}

// SetupSuite runs once before all tests in the suite
func (suite *UserIntegrationTestSuite) SetupSuite() {
    // Setup test database
    testDB, err := database.NewTestConnection()
    suite.Require().NoError(err)
    suite.db = testDB
    
    // Run migrations
    err = suite.db.AutoMigrate(&models.User{})
    suite.Require().NoError(err)
    
    // Setup router with real dependencies
    gin.SetMode(gin.TestMode)
    suite.router = gin.New()
    
    userRepo := repositories.NewUserRepository(suite.db)
    userService := services.NewUserService(userRepo)
    userHandler := handlers.NewUserHandler(userService)
    
    // Setup routes
    api := suite.router.Group("/api/v1")
    {
        users := api.Group("/users")
        {
            users.POST("", userHandler.CreateUser)
            users.GET("/:id", userHandler.GetUser)
            users.PUT("/:id", userHandler.UpdateUser)
            users.DELETE("/:id", userHandler.DeleteUser)
        }
    }
    
    // Create test server
    suite.server = httptest.NewServer(suite.router)
}

// TearDownSuite runs once after all tests in the suite
func (suite *UserIntegrationTestSuite) TearDownSuite() {
    suite.server.Close()
    
    // Clean up database
    sqlDB, _ := suite.db.DB()
    sqlDB.Close()
}

// SetupTest runs before each test
func (suite *UserIntegrationTestSuite) SetupTest() {
    // Clean database before each test
    suite.db.Exec("DELETE FROM users")
}

// TestUserLifecycle tests complete user CRUD operations
func (suite *UserIntegrationTestSuite) TestUserLifecycle() {
    // Test user creation
    createReq := map[string]interface{}{
        "username":  "testuser",
        "email":     "test@example.com",
        "full_name": "Test User",
    }
    
    body, _ := json.Marshal(createReq)
    resp, err := http.Post(suite.server.URL+"/api/v1/users", "application/json", bytes.NewBuffer(body))
    suite.Require().NoError(err)
    defer resp.Body.Close()
    
    suite.Equal(http.StatusCreated, resp.StatusCode)
    
    var createResponse map[string]interface{}
    err = json.NewDecoder(resp.Body).Decode(&createResponse)
    suite.Require().NoError(err)
    
    suite.Equal("User created successfully", createResponse["message"])
    user := createResponse["user"].(map[string]interface{})
    userID := int(user["id"].(float64))
    
    // Test user retrieval
    resp, err = http.Get(fmt.Sprintf("%s/api/v1/users/%d", suite.server.URL, userID))
    suite.Require().NoError(err)
    defer resp.Body.Close()
    
    suite.Equal(http.StatusOK, resp.StatusCode)
    
    var getResponse map[string]interface{}
    err = json.NewDecoder(resp.Body).Decode(&getResponse)
    suite.Require().NoError(err)
    
    retrievedUser := getResponse["user"].(map[string]interface{})
    suite.Equal("testuser", retrievedUser["username"])
    suite.Equal("test@example.com", retrievedUser["email"])
    
    // Test user update
    updateReq := map[string]interface{}{
        "email":     "updated@example.com",
        "full_name": "Updated User",
    }
    
    body, _ = json.Marshal(updateReq)
    req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/v1/users/%d", suite.server.URL, userID), bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    
    client := &http.Client{}
    resp, err = client.Do(req)
    suite.Require().NoError(err)
    defer resp.Body.Close()
    
    suite.Equal(http.StatusOK, resp.StatusCode)
    
    var updateResponse map[string]interface{}
    err = json.NewDecoder(resp.Body).Decode(&updateResponse)
    suite.Require().NoError(err)
    
    updatedUser := updateResponse["user"].(map[string]interface{})
    suite.Equal("updated@example.com", updatedUser["email"])
    suite.Equal("Updated User", updatedUser["full_name"])
}

// TestUserValidation tests input validation
func (suite *UserIntegrationTestSuite) TestUserValidation() {
    tests := []struct {
        name           string
        payload        map[string]interface{}
        expectedStatus int
        expectedError  string
    }{
        {
            name: "missing username",
            payload: map[string]interface{}{
                "email":     "test@example.com",
                "full_name": "Test User",
            },
            expectedStatus: http.StatusBadRequest,
            expectedError:  "Invalid input",
        },
        {
            name: "invalid email",
            payload: map[string]interface{}{
                "username":  "testuser",
                "email":     "invalid-email",
                "full_name": "Test User",
            },
            expectedStatus: http.StatusBadRequest,
            expectedError:  "Invalid input",
        },
        {
            name: "short username",
            payload: map[string]interface{}{
                "username":  "ab",
                "email":     "test@example.com",
                "full_name": "Test User",
            },
            expectedStatus: http.StatusBadRequest,
            expectedError:  "Invalid input",
        },
    }
    
    for _, tt := range tests {
        suite.Run(tt.name, func() {
            body, _ := json.Marshal(tt.payload)
            resp, err := http.Post(suite.server.URL+"/api/v1/users", "application/json", bytes.NewBuffer(body))
            suite.Require().NoError(err)
            defer resp.Body.Close()
            
            suite.Equal(tt.expectedStatus, resp.StatusCode)
            
            var response map[string]interface{}
            err = json.NewDecoder(resp.Body).Decode(&response)
            suite.Require().NoError(err)
            
            suite.Equal(tt.expectedError, response["error"])
        })
    }
}

// TestConcurrentUserCreation tests race conditions
func (suite *UserIntegrationTestSuite) TestConcurrentUserCreation() {
    const numGoroutines = 10
    results := make(chan error, numGoroutines)
    
    for i := 0; i < numGoroutines; i++ {
        go func(index int) {
            createReq := map[string]interface{}{
                "username":  fmt.Sprintf("user%d", index),
                "email":     fmt.Sprintf("user%d@example.com", index),
                "full_name": fmt.Sprintf("User %d", index),
            }
            
            body, _ := json.Marshal(createReq)
            resp, err := http.Post(suite.server.URL+"/api/v1/users", "application/json", bytes.NewBuffer(body))
            if err != nil {
                results <- err
                return
            }
            defer resp.Body.Close()
            
            if resp.StatusCode != http.StatusCreated {
                results <- fmt.Errorf("unexpected status code: %d", resp.StatusCode)
                return
            }
            
            results <- nil
        }(i)
    }
    
    // Wait for all goroutines to complete
    for i := 0; i < numGoroutines; i++ {
        err := <-results
        suite.NoError(err)
    }
    
    // Verify all users were created
    var count int64
    suite.db.Model(&models.User{}).Count(&count)
    suite.Equal(int64(numGoroutines), count)
}

// Run the integration test suite
func TestUserIntegrationSuite(t *testing.T) {
    if os.Getenv("INTEGRATION_TEST") != "true" {
        t.Skip("Skipping integration tests. Set INTEGRATION_TEST=true to run.")
    }
    
    suite.Run(t, new(UserIntegrationTestSuite))
}

// database/test_connection.go
package database

import (
    "fmt"
    "os"
    
    "gorm.io/driver/postgres"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

// NewTestConnection creates a new database connection for testing
func NewTestConnection() (*gorm.DB, error) {
    // Use SQLite for fast tests, or PostgreSQL for more realistic tests
    testDriver := os.Getenv("TEST_DB_DRIVER")
    if testDriver == "" {
        testDriver = "sqlite"
    }
    
    var db *gorm.DB
    var err error
    
    switch testDriver {
    case "sqlite":
        db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
            Logger: logger.Default.LogMode(logger.Silent),
        })
    case "postgres":
        dsn := fmt.Sprintf(
            "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
            getEnv("TEST_DB_HOST", "localhost"),
            getEnv("TEST_DB_USER", "test"),
            getEnv("TEST_DB_PASSWORD", "test"),
            getEnv("TEST_DB_NAME", "test_db"),
            getEnv("TEST_DB_PORT", "5432"),
        )
        db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
            Logger: logger.Default.LogMode(logger.Silent),
        })
    default:
        return nil, fmt.Errorf("unsupported test database driver: %s", testDriver)
    }
    
    if err != nil {
        return nil, fmt.Errorf("failed to connect to test database: %w", err)
    }

    if testDriver == "sqlite" {
        sqlDB, err := db.DB()
        if err != nil { return nil, err }
        sqlDB.SetMaxOpenConns(1) // Each :memory: connection otherwise sees a separate schema.
    }
    
    return db, nil
}

func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

Example 3: Advanced Testing with Testify and Test Doubles

// services/mocks/user_service.go
// This file is generated using mockery or written manually
package mocks

import (
    "github.com/stretchr/testify/mock"
    "github.com/your-app/models"
)

// UserService is a mock implementation of services.UserService
type UserService struct {
    mock.Mock
}

// CreateUser mocks the CreateUser method
func (m *UserService) CreateUser(username, email, fullName string) (*models.User, error) {
    args := m.Called(username, email, fullName)
    return args.Get(0).(*models.User), args.Error(1)
}

// GetUserByID mocks the GetUserByID method
func (m *UserService) GetUserByID(id uint) (*models.User, error) {
    args := m.Called(id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*models.User), args.Error(1)
}

// UpdateUser mocks the UpdateUser method
func (m *UserService) UpdateUser(id uint, email, fullName string) (*models.User, error) {
    args := m.Called(id, email, fullName)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*models.User), args.Error(1)
}

// DeleteUser mocks the DeleteUser method
func (m *UserService) DeleteUser(id uint) error {
    args := m.Called(id)
    return args.Error(0)
}

// middleware/auth_middleware_test.go
package middleware

import (
    "net/http"
    "net/http/httptest"
    "testing"
    
    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
    "github.com/your-app/auth"
)

func TestAuthMiddleware(t *testing.T) {
    // Setup JWT manager for testing
    jwtConfig := auth.JWTConfig{
        SecretKey:            []byte("test-secret-key"),
        AccessTokenDuration:  time.Hour,
        RefreshTokenDuration: time.Hour * 24,
        Issuer:              "test-issuer",
    }
    jwtManager := auth.NewJWTManager(jwtConfig)
    
    tests := []struct {
        name           string
        setupRequest   func() *http.Request
        expectedStatus int
        expectedBody   string
    }{
        {
            name: "valid token",
            setupRequest: func() *http.Request {
                // Generate valid token
                tokenPair, _ := jwtManager.GenerateTokenPair(1, "testuser", "user")
                
                req, _ := http.NewRequest("GET", "/protected", nil)
                req.Header.Set("Authorization", "Bearer "+tokenPair.AccessToken)
                return req
            },
            expectedStatus: http.StatusOK,
        },
        {
            name: "missing authorization header",
            setupRequest: func() *http.Request {
                req, _ := http.NewRequest("GET", "/protected", nil)
                return req
            },
            expectedStatus: http.StatusUnauthorized,
            expectedBody:   "Authorization header required",
        },
        {
            name: "invalid token format",
            setupRequest: func() *http.Request {
                req, _ := http.NewRequest("GET", "/protected", nil)
                req.Header.Set("Authorization", "InvalidFormat")
                return req
            },
            expectedStatus: http.StatusUnauthorized,
            expectedBody:   "Invalid authorization header format",
        },
        {
            name: "expired token",
            setupRequest: func() *http.Request {
                // Create expired token (this would require mocking time or using a very short expiration)
                expiredConfig := auth.JWTConfig{
                    SecretKey:            []byte("test-secret-key"),
                    AccessTokenDuration:  -time.Hour, // Negative duration for expired token
                    RefreshTokenDuration: time.Hour * 24,
                    Issuer:              "test-issuer",
                }
                expiredJWTManager := auth.NewJWTManager(expiredConfig)
                tokenPair, _ := expiredJWTManager.GenerateTokenPair(1, "testuser", "user")
                
                req, _ := http.NewRequest("GET", "/protected", nil)
                req.Header.Set("Authorization", "Bearer "+tokenPair.AccessToken)
                return req
            },
            expectedStatus: http.StatusUnauthorized,
            expectedBody:   "Invalid or expired token",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Setup
            gin.SetMode(gin.TestMode)
            router := gin.New()
            router.Use(AuthMiddleware(jwtManager))
            router.GET("/protected", func(c *gin.Context) {
                c.JSON(http.StatusOK, gin.H{"message": "success"})
            })
            
            // Execute
            req := tt.setupRequest()
            w := httptest.NewRecorder()
            router.ServeHTTP(w, req)
            
            // Assert
            assert.Equal(t, tt.expectedStatus, w.Code)
            if tt.expectedBody != "" {
                assert.Contains(t, w.Body.String(), tt.expectedBody)
            }
        })
    }
}

// Test helper functions
func TestHelper_TokenGeneration(t *testing.T) {
    jwtConfig := auth.JWTConfig{
        SecretKey:            []byte("test-secret-key"),
        AccessTokenDuration:  time.Hour,
        RefreshTokenDuration: time.Hour * 24,
        Issuer:              "test-issuer",
    }
    jwtManager := auth.NewJWTManager(jwtConfig)
    
    t.Run("valid token generation", func(t *testing.T) {
        tokenPair, err := jwtManager.GenerateTokenPair(1, "testuser", "user")
        
        assert.NoError(t, err)
        assert.NotEmpty(t, tokenPair.AccessToken)
        assert.NotEmpty(t, tokenPair.RefreshToken)
        assert.Equal(t, "Bearer", tokenPair.TokenType)
    })
    
    t.Run("token validation", func(t *testing.T) {
        tokenPair, _ := jwtManager.GenerateTokenPair(1, "testuser", "user")
        
        claims, err := jwtManager.ValidateToken(tokenPair.AccessToken)
        
        assert.NoError(t, err)
        assert.Equal(t, uint(1), claims.UserID)
        assert.Equal(t, "testuser", claims.Username)
        assert.Equal(t, "user", claims.Role)
        assert.Equal(t, "access", claims.TokenType)
    })
}

// Performance testing
func BenchmarkAuthMiddleware(b *testing.B) {
    jwtConfig := auth.JWTConfig{
        SecretKey:            []byte("test-secret-key"),
        AccessTokenDuration:  time.Hour,
        RefreshTokenDuration: time.Hour * 24,
        Issuer:              "test-issuer",
    }
    jwtManager := auth.NewJWTManager(jwtConfig)
    tokenPair, _ := jwtManager.GenerateTokenPair(1, "testuser", "user")
    
    gin.SetMode(gin.TestMode)
    router := gin.New()
    router.Use(AuthMiddleware(jwtManager))
    router.GET("/protected", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"message": "success"})
    })
    
    req, _ := http.NewRequest("GET", "/protected", nil)
    req.Header.Set("Authorization", "Bearer "+tokenPair.AccessToken)
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
    }
}

6. Hands-On Practice (30-40 minutes)

Exercise 1: Test Coverage Analysis (10 minutes)

Objective: Measure and improve test coverage for your application

Instructions:

  1. Run tests with coverage reporting: go test -cover ./...
  2. Generate detailed coverage report: go test -coverprofile=coverage.out ./...
  3. View coverage in browser: go tool cover -html=coverage.out
  4. Identify untested code paths and add tests to reach 80%+ coverage
  5. Set up coverage thresholds in CI/CD pipeline

Starter Code:

// Create a Makefile or script for coverage testing
// Makefile
test-coverage:
	go test -coverprofile=coverage.out ./...
	go tool cover -html=coverage.out -o coverage.html
	go tool cover -func=coverage.out | grep total
	
test-coverage-threshold:
	@coverage=$$(go test -coverprofile=coverage.out ./... | grep -o '[0-9]*\.[0-9]*%' | head -1 | sed 's/%//'); \
	if [ "$$(echo "$$coverage < 80" | bc)" -eq 1 ]; then \
		echo "Coverage $$coverage% is below threshold 80%"; \
		exit 1; \
	else \
		echo "Coverage $$coverage% meets threshold"; \
	fi

Exercise 2: Database Testing with Transactions (15 minutes)

Objective: Implement efficient database testing with transaction rollback

Requirements:

  • Use database transactions for test isolation
  • Create test fixtures and factories
  • Implement parallel test execution
  • Add database seeding for complex scenarios
  • Test database constraints and relationships

Hints:

  • Use suite.db.Begin() to start transactions
  • Rollback transactions in test cleanup
  • Create helper functions for common test data

Solution:

// tests/fixtures/user_factory.go
package fixtures

import (
    "github.com/your-app/models"
    "gorm.io/gorm"
)

type UserFactory struct {
    db *gorm.DB
}

func NewUserFactory(db *gorm.DB) *UserFactory {
    return &UserFactory{db: db}
}

func (f *UserFactory) CreateUser(overrides ...func(*models.User)) *models.User {
    user := &models.User{
        Username: "testuser",
        Email:    "test@example.com",
        FullName: "Test User",
        Active:   true,
    }
    
    // Apply overrides
    for _, override := range overrides {
        override(user)
    }
    
    f.db.Create(user)
    return user
}

func (f *UserFactory) CreateUsers(count int) []*models.User {
    users := make([]*models.User, count)
    for i := 0; i < count; i++ {
        users[i] = f.CreateUser(func(u *models.User) {
            u.Username = fmt.Sprintf("testuser%d", i)
            u.Email = fmt.Sprintf("test%d@example.com", i)
        })
    }
    return users
}

// Test with transaction isolation
func (suite *UserIntegrationTestSuite) TestWithTransaction() {
    tx := suite.db.Begin()
    defer tx.Rollback()
    
    userFactory := fixtures.NewUserFactory(tx)
    user := userFactory.CreateUser()
    
    // Test operations within transaction
    // Changes are automatically rolled back
}

Exercise 3: End-to-End Testing (15 minutes)

Objective: Create comprehensive E2E tests for user workflows

Challenge: Implement E2E tests that:

  • Test complete user registration and login flow
  • Verify email verification process
  • Test password reset functionality
  • Check role-based access control
  • Validate API rate limiting
  • Test file upload and processing

Extension Ideas:

  • Add browser automation with Selenium/Playwright
  • Test WebSocket connections
  • Validate real-time features
  • Test mobile app API endpoints
  • Add performance testing with load simulation

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Tests are slow and unreliable Cause: Using real external services or not cleaning up test data Solution: Use test doubles, in-memory databases, and proper cleanup

  2. Problem: Tests pass individually but fail when run together Cause: Shared state between tests or race conditions Solution: Ensure test isolation with transactions or cleanup functions

  3. Problem: Mocks are too complex and brittle Cause: Over-mocking or mocking at the wrong level Solution: Mock at architectural boundaries, use fake implementations for complex dependencies

Debugging Tips

  • Use t.Helper() in test helper functions to get correct line numbers
  • Run single tests with go test -run TestName
  • Use -v flag for verbose output
  • Add logging to tests for debugging complex failures
  • Use go test -race to detect race conditions

8. Best Practices and Professional Tips (5 minutes)

Industry Standards

  • Follow the testing pyramid: more unit tests, fewer E2E tests
  • Write tests first (TDD) or alongside code development
  • Keep tests simple, readable, and maintainable
  • Use descriptive test names that explain the scenario
  • Maintain test code quality as carefully as production code

Next Steps in Your Learning Journey

  • Learn property-based testing with tools like GoCheck
  • Explore chaos engineering and fault injection testing
  • Study test automation and CI/CD integration
  • Practice mutation testing to validate test quality
  • Learn contract testing for microservices

9. Summary and Review (5 minutes)

Key Takeaways

  1. Comprehensive testing requires unit, integration, and E2E tests
  2. Test doubles enable fast, reliable testing by isolating dependencies
  3. Proper test structure with setup/teardown ensures reliable test execution
  4. Coverage analysis helps identify untested code but quality matters more than quantity

Quick Review Questions

  1. How do you structure tests for a Gin handler with external dependencies?
  2. When should you use mocks vs. integration tests?
  3. What strategies ensure reliable database testing?
  4. How do you measure and improve test coverage effectively?

For Instructors

Teaching Tips

  • Demonstrate both good and bad test examples to show the difference
  • Show how to debug failing tests step by step
  • Emphasize the importance of test maintenance and refactoring
  • Use real examples from production codebases when possible
  • Encourage students to think about edge cases and error conditions

Assessment Ideas

  • Build a complete test suite for a CRUD application
  • Create integration tests for authentication flows
  • Implement mocking for external API dependencies
  • Set up automated testing in CI/CD pipeline
  • Write performance benchmarks for critical code paths

Adaptation Notes

  • For beginners: Focus on basic unit testing before advanced topics
  • For advanced students: Introduce property-based testing and advanced patterns
  • Online delivery: Provide pre-configured test environments and docker containers
  • Time management: Integration testing section can be extended for deeper database testing