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:
- Testing Fundamentals - Test types, patterns, and Go testing basics
- Unit Testing - Testing handlers, middleware, and business logic in isolation
- Integration Testing - Testing complete request-response cycles
- Test Doubles - Mocking dependencies for reliable, fast tests
- Database Testing - Managing test data and database states
- Advanced Testing - Coverage analysis, benchmarking, and E2E testing
- 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:
- Unit Tests (70%): Fast, isolated, test individual components
- Integration Tests (20%): Test component interactions
- 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:
- Red: Write a failing test
- Green: Write minimal code to pass
- 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
Testand take*testing.T - Benchmark functions start with
Benchmarkand take*testing.B - Use
go testcommand to run tests
HTTP Testing with httptest Package
Go’s httptest package provides tools for testing HTTP handlers:
httptest.NewRecorder(): Records HTTP responseshttptest.NewServer(): Creates test HTTP servershttptest.NewRequest(): Creates HTTP requests for testing
Gin Testing Patterns
Gin applications can be tested in multiple ways:
- Unit testing handlers: Test handler functions directly
- Router testing: Test routes with Gin’s test mode
- Middleware testing: Test middleware functions in isolation
- 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
-
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.
-
Misconception: “Mocking everything makes tests better” Reality: Over-mocking can make tests brittle and less valuable. Mock judiciously, especially at architectural boundaries.
-
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:
- Run tests with coverage reporting:
go test -cover ./... - Generate detailed coverage report:
go test -coverprofile=coverage.out ./... - View coverage in browser:
go tool cover -html=coverage.out - Identify untested code paths and add tests to reach 80%+ coverage
- 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
-
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
-
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
-
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
-vflag for verbose output - Add logging to tests for debugging complex failures
- Use
go test -raceto 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
- Comprehensive testing requires unit, integration, and E2E tests
- Test doubles enable fast, reliable testing by isolating dependencies
- Proper test structure with setup/teardown ensures reliable test execution
- Coverage analysis helps identify untested code but quality matters more than quantity
Quick Review Questions
- How do you structure tests for a Gin handler with external dependencies?
- When should you use mocks vs. integration tests?
- What strategies ensure reliable database testing?
- 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