Testing and Best Practices
Learning Objectives: By the end of this lesson, you will be able to:
- Write effective unit tests for GORM-based code
- Implement integration tests with test databases
- Use mocking and interfaces for testable database code
- Apply GORM best practices for production applications
- Structure code with repository and service patterns
- Handle errors and logging professionally
- Design maintainable database layer architecture
1. Welcome and Introduction (5 minutes)
Icebreaker Activity
How confident are you that your database code works correctly? Would you bet your weekend that deploying a new feature won’t corrupt data or cause unexpected errors? Testing database code is notoriously challenging, but it’s essential for building reliable applications. Today, we’ll learn strategies to test GORM code effectively and explore best practices that will make your database layer bulletproof.
Lesson Overview
- Duration: 150 minutes
- Prerequisites: All previous lessons completed, understanding of Go testing
- Materials Needed: Working GORM setup, Go testing knowledge
2. Learning Objectives and Session Outline (5 minutes)
What We’ll Cover Today:
- Unit Testing - Testing business logic in isolation
- Integration Testing - Testing with real databases
- Mocking Strategies - Using interfaces and mock databases
- Code Architecture - Repository and service patterns
- Error Handling - Professional error management
- Production Best Practices - Security, logging, monitoring
- Hands-on practice building a testable application
Success Criteria:
By the end of this session, you should be able to write comprehensive tests for GORM code and apply professional best practices.
3. Foundation Knowledge (15-20 minutes)
Core Concepts
Testing Pyramid for Database Code
┌────────────────────────────────────────────────────────────────┐
│ Testing Pyramid │
├────────────────────────────────────────────────────────────────┤
│ │
│ /\ │
│ / \ │
│ /E2E \ (Few, slow, expensive) │
│ /------\ │
│ / \ │
│ /Integration\ (Some, medium speed) │
│ /--------------\ │
│ / \ │
│ / Unit Tests \ (Many, fast, cheap) │
│ /--------------------\ │
│ │
└────────────────────────────────────────────────────────────────┘
Real-World Analogy: Think of testing like inspecting a car. Unit tests are like checking individual parts (battery, brakes). Integration tests are like testing how systems work together (engine + transmission). E2E tests are like taking the car for a test drive on the road.
Why This Matters: Database bugs can corrupt data, cause security breaches, and bring down applications. Proper testing catches issues before they reach production.
Testing Approaches
- In-Memory Database: SQLite in-memory for fast unit tests
- Test Database: Dedicated database instance for integration tests
- Mock Repository: Interface-based mocking for business logic tests
- Testcontainers: Docker containers for realistic testing
Key Terminology
- Unit Test: Tests a single function in isolation
- Integration Test: Tests multiple components together
- Mock: Fake implementation for testing
- Fixture: Pre-set data for tests
- Test Isolation: Each test runs independently
- Repository Pattern: Abstraction over data access
4. Detailed Explanation and Examples (25-30 minutes)
Architecture for Testable Code
┌────────────────────────────────────────────────────────────────┐
│ Testable Architecture │
├────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Handler Layer │ │
│ │ HTTP handlers, CLI commands, GraphQL resolvers │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Service Layer │ │
│ │ Business logic, validation, orchestration │ │
│ │ (Depends on Repository Interface) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Repository Layer │ │
│ │ Data access, GORM operations │ │
│ │ (Implements Repository Interface) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Database │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Common Misconceptions
-
Misconception: “Database tests are always slow” Reality: In-memory SQLite tests run in milliseconds.
-
Misconception: “Mocking is always necessary” Reality: Integration tests with real databases catch more bugs.
-
Misconception: “You need 100% code coverage” Reality: Focus on critical paths and edge cases.
5. Live Coding Demonstration (20-25 minutes)
Example 1: Repository Pattern and Interface
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
)
// Domain models
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"size:255;uniqueIndex"`
Name string `gorm:"size:100"`
Status string `gorm:"size:20;default:'active'"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// Repository interface - enables mocking
type UserRepository interface {
Create(ctx context.Context, user *User) error
FindByID(ctx context.Context, id uint) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
FindAll(ctx context.Context, filters UserFilters) ([]User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id uint) error
Count(ctx context.Context, filters UserFilters) (int64, error)
}
type UserFilters struct {
Status string
Search string
Page int
PageSize int
}
// GormUserRepository implements UserRepository with GORM
type GormUserRepository struct {
db *gorm.DB
}
func NewGormUserRepository(db *gorm.DB) *GormUserRepository {
return &GormUserRepository{db: db}
}
func (r *GormUserRepository) Create(ctx context.Context, user *User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *GormUserRepository) FindByID(ctx context.Context, id uint) (*User, error) {
var user User
err := r.db.WithContext(ctx).First(&user, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // Return nil, nil for not found (or define custom error)
}
if err != nil { return nil, err }
return &user, nil
}
func (r *GormUserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil { return nil, err }
return &user, nil
}
func (r *GormUserRepository) FindAll(ctx context.Context, filters UserFilters) ([]User, error) {
var users []User
query := r.db.WithContext(ctx)
if filters.Status != "" {
query = query.Where("status = ?", filters.Status)
}
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where("name LIKE ? OR email LIKE ?", search, search)
}
// Pagination
if filters.Page > 0 && filters.PageSize > 0 {
offset := (filters.Page - 1) * filters.PageSize
query = query.Offset(offset).Limit(filters.PageSize)
}
err := query.Order("created_at DESC").Find(&users).Error
return users, err
}
func (r *GormUserRepository) Update(ctx context.Context, user *User) error {
if user.ID == 0 { return gorm.ErrRecordNotFound }
// Save can insert a missing row; update only intended fields, including zero values.
result := r.db.WithContext(ctx).Model(&User{}).Where("id = ?", user.ID).
Select("Email", "Name", "Status").Updates(user)
if result.Error != nil { return result.Error }
if result.RowsAffected == 0 { return gorm.ErrRecordNotFound }
return nil
}
func (r *GormUserRepository) Delete(ctx context.Context, id uint) error {
result := r.db.WithContext(ctx).Delete(&User{}, id)
if result.Error != nil { return result.Error }
if result.RowsAffected == 0 { return gorm.ErrRecordNotFound }
return nil
}
func (r *GormUserRepository) Count(ctx context.Context, filters UserFilters) (int64, error) {
var count int64
query := r.db.WithContext(ctx).Model(&User{})
if filters.Status != "" {
query = query.Where("status = ?", filters.Status)
}
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where("name LIKE ? OR email LIKE ?", search, search)
}
err := query.Count(&count).Error
return count, err
}
Example 2: Service Layer with Business Logic
package service
import (
"context"
"errors"
"regexp"
"strings"
"yourapp/repository"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailAlreadyExists = errors.New("email already exists")
ErrInvalidEmail = errors.New("invalid email format")
ErrInvalidName = errors.New("name is required")
)
// UserService handles business logic
type UserService struct {
repo repository.UserRepository
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
// CreateUser creates a new user with validation
func (s *UserService) CreateUser(ctx context.Context, email, name string) (*repository.User, error) {
// Validate email
email = strings.ToLower(strings.TrimSpace(email))
if !isValidEmail(email) {
return nil, ErrInvalidEmail
}
// Validate name
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrInvalidName
}
// Check for duplicate email
existing, err := s.repo.FindByEmail(ctx, email)
if err != nil {
return nil, err
}
if existing != nil {
return nil, ErrEmailAlreadyExists
}
// Create user
user := &repository.User{
Email: email,
Name: name,
Status: "active",
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// GetUser retrieves a user by ID
func (s *UserService) GetUser(ctx context.Context, id uint) (*repository.User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
// UpdateUserStatus updates user status with validation
func (s *UserService) UpdateUserStatus(ctx context.Context, id uint, status string) error {
validStatuses := map[string]bool{"active": true, "inactive": true, "banned": true}
if !validStatuses[status] {
return errors.New("invalid status")
}
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return err
}
if user == nil {
return ErrUserNotFound
}
user.Status = status
return s.repo.Update(ctx, user)
}
// DeactivateUser soft deletes a user
func (s *UserService) DeactivateUser(ctx context.Context, id uint) error {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return err
}
if user == nil {
return ErrUserNotFound
}
return s.repo.Delete(ctx, id)
}
// ListUsers returns paginated user list
func (s *UserService) ListUsers(ctx context.Context, page, pageSize int, status, search string) ([]repository.User, int64, error) {
filters := repository.UserFilters{
Status: status,
Search: search,
Page: page,
PageSize: pageSize,
}
users, err := s.repo.FindAll(ctx, filters)
if err != nil {
return nil, 0, err
}
total, err := s.repo.Count(ctx, filters)
if err != nil {
return nil, 0, err
}
return users, total, nil
}
func isValidEmail(email string) bool {
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
match, _ := regexp.MatchString(pattern, email)
return match
}
Example 3: Unit Tests with Mocks
package service_test
import (
"context"
"errors"
"testing"
"yourapp/repository"
"yourapp/service"
)
// MockUserRepository implements UserRepository for testing
type MockUserRepository struct {
users map[uint]*repository.User
emailIndex map[string]*repository.User
nextID uint
// For tracking calls
CreateCalls int
FindByIDCalls int
FindByEmailCalls int
// For simulating errors
CreateError error
FindError error
}
func NewMockUserRepository() *MockUserRepository {
return &MockUserRepository{
users: make(map[uint]*repository.User),
emailIndex: make(map[string]*repository.User),
nextID: 1,
}
}
func (m *MockUserRepository) Create(ctx context.Context, user *repository.User) error {
m.CreateCalls++
if m.CreateError != nil {
return m.CreateError
}
user.ID = m.nextID
m.nextID++
m.users[user.ID] = user
m.emailIndex[user.Email] = user
return nil
}
func (m *MockUserRepository) FindByID(ctx context.Context, id uint) (*repository.User, error) {
m.FindByIDCalls++
if m.FindError != nil {
return nil, m.FindError
}
return m.users[id], nil
}
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*repository.User, error) {
m.FindByEmailCalls++
if m.FindError != nil {
return nil, m.FindError
}
return m.emailIndex[email], nil
}
func (m *MockUserRepository) FindAll(ctx context.Context, filters repository.UserFilters) ([]repository.User, error) {
var result []repository.User
for _, u := range m.users {
if filters.Status == "" || u.Status == filters.Status {
result = append(result, *u)
}
}
return result, nil
}
func (m *MockUserRepository) Update(ctx context.Context, user *repository.User) error {
if _, exists := m.users[user.ID]; !exists {
return errors.New("user not found")
}
m.users[user.ID] = user
m.emailIndex[user.Email] = user
return nil
}
func (m *MockUserRepository) Delete(ctx context.Context, id uint) error {
if user, exists := m.users[id]; exists {
delete(m.emailIndex, user.Email)
delete(m.users, id)
}
return nil
}
func (m *MockUserRepository) Count(ctx context.Context, filters repository.UserFilters) (int64, error) {
var count int64
for _, u := range m.users {
if filters.Status == "" || u.Status == filters.Status {
count++
}
}
return count, nil
}
// ===== UNIT TESTS =====
func TestUserService_CreateUser_Success(t *testing.T) {
// Arrange
mockRepo := NewMockUserRepository()
svc := service.NewUserService(mockRepo)
ctx := context.Background()
// Act
user, err := svc.CreateUser(ctx, "test@example.com", "Test User")
// Assert
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if user == nil {
t.Fatal("Expected user, got nil")
}
if user.Email != "test@example.com" {
t.Errorf("Expected email 'test@example.com', got '%s'", user.Email)
}
if user.Name != "Test User" {
t.Errorf("Expected name 'Test User', got '%s'", user.Name)
}
if mockRepo.CreateCalls != 1 {
t.Errorf("Expected 1 Create call, got %d", mockRepo.CreateCalls)
}
}
func TestUserService_CreateUser_InvalidEmail(t *testing.T) {
mockRepo := NewMockUserRepository()
svc := service.NewUserService(mockRepo)
ctx := context.Background()
testCases := []struct {
name string
email string
}{
{"empty", ""},
{"no at sign", "invalid"},
{"no domain", "test@"},
{"spaces", "test @example.com"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.CreateUser(ctx, tc.email, "Test")
if !errors.Is(err, service.ErrInvalidEmail) {
t.Errorf("Expected ErrInvalidEmail, got %v", err)
}
})
}
}
func TestUserService_CreateUser_DuplicateEmail(t *testing.T) {
mockRepo := NewMockUserRepository()
svc := service.NewUserService(mockRepo)
ctx := context.Background()
// Create first user
_, err := svc.CreateUser(ctx, "duplicate@example.com", "First User")
if err != nil {
t.Fatalf("First creation failed: %v", err)
}
// Try to create second user with same email
_, err = svc.CreateUser(ctx, "duplicate@example.com", "Second User")
if !errors.Is(err, service.ErrEmailAlreadyExists) {
t.Errorf("Expected ErrEmailAlreadyExists, got %v", err)
}
}
func TestUserService_CreateUser_EmptyName(t *testing.T) {
mockRepo := NewMockUserRepository()
svc := service.NewUserService(mockRepo)
ctx := context.Background()
_, err := svc.CreateUser(ctx, "test@example.com", " ")
if !errors.Is(err, service.ErrInvalidName) {
t.Errorf("Expected ErrInvalidName, got %v", err)
}
}
func TestUserService_GetUser_NotFound(t *testing.T) {
mockRepo := NewMockUserRepository()
svc := service.NewUserService(mockRepo)
ctx := context.Background()
_, err := svc.GetUser(ctx, 999)
if !errors.Is(err, service.ErrUserNotFound) {
t.Errorf("Expected ErrUserNotFound, got %v", err)
}
}
func TestUserService_UpdateStatus_InvalidStatus(t *testing.T) {
mockRepo := NewMockUserRepository()
svc := service.NewUserService(mockRepo)
ctx := context.Background()
// Create user first
user, _ := svc.CreateUser(ctx, "test@example.com", "Test")
// Try invalid status
err := svc.UpdateUserStatus(ctx, user.ID, "invalid")
if err == nil {
t.Error("Expected error for invalid status")
}
}
Example 4: Integration Tests with Test Database
package repository_test
import (
"context"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"yourapp/repository"
)
// TestDB creates an in-memory database for testing
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
// :memory: belongs to one connection; prevent a pooled connection from
// seeing an empty schema. Give parallel tests separate databases.
sqlDB, err := db.DB()
if err != nil { t.Fatal(err) }
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
// Migrate schema
err = db.AutoMigrate(&repository.User{})
if err != nil {
t.Fatalf("Failed to migrate: %v", err)
}
return db
}
func TestGormUserRepository_Create(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormUserRepository(db)
ctx := context.Background()
user := &repository.User{
Email: "test@example.com",
Name: "Test User",
Status: "active",
}
err := repo.Create(ctx, user)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if user.ID == 0 {
t.Error("Expected user ID to be set")
}
// Verify in database
var found repository.User
if err := db.First(&found, user.ID).Error; err != nil { t.Fatal(err) }
if found.Email != user.Email {
t.Errorf("Expected email %s, got %s", user.Email, found.Email)
}
}
func TestGormUserRepository_FindByID(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormUserRepository(db)
ctx := context.Background()
// Create test user
user := &repository.User{
Email: "find@example.com",
Name: "Find User",
Status: "active",
}
db.Create(user)
// Test find
found, err := repo.FindByID(ctx, user.ID)
if err != nil {
t.Fatalf("FindByID failed: %v", err)
}
if found == nil {
t.Fatal("Expected to find user")
}
if found.Email != user.Email {
t.Errorf("Expected email %s, got %s", user.Email, found.Email)
}
// Test not found
notFound, err := repo.FindByID(ctx, 99999)
if err != nil {
t.Fatalf("FindByID failed: %v", err)
}
if notFound != nil {
t.Error("Expected nil for non-existent user")
}
}
func TestGormUserRepository_FindByEmail(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormUserRepository(db)
ctx := context.Background()
// Create test user
user := &repository.User{
Email: "unique@example.com",
Name: "Unique User",
Status: "active",
}
db.Create(user)
// Test find
found, err := repo.FindByEmail(ctx, "unique@example.com")
if err != nil {
t.Fatalf("FindByEmail failed: %v", err)
}
if found == nil {
t.Fatal("Expected to find user")
}
// Test not found
notFound, err := repo.FindByEmail(ctx, "nonexistent@example.com")
if err != nil {
t.Fatalf("FindByEmail failed: %v", err)
}
if notFound != nil {
t.Error("Expected nil for non-existent email")
}
}
func TestGormUserRepository_FindAll_WithFilters(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormUserRepository(db)
ctx := context.Background()
// Create test users
users := []repository.User{
{Email: "active1@example.com", Name: "Active One", Status: "active"},
{Email: "active2@example.com", Name: "Active Two", Status: "active"},
{Email: "inactive@example.com", Name: "Inactive User", Status: "inactive"},
}
for i := range users {
db.Create(&users[i])
}
// Test filter by status
filters := repository.UserFilters{Status: "active"}
result, err := repo.FindAll(ctx, filters)
if err != nil {
t.Fatalf("FindAll failed: %v", err)
}
if len(result) != 2 {
t.Errorf("Expected 2 active users, got %d", len(result))
}
// Test search
filters = repository.UserFilters{Search: "One"}
result, err = repo.FindAll(ctx, filters)
if err != nil {
t.Fatalf("FindAll failed: %v", err)
}
if len(result) != 1 {
t.Errorf("Expected 1 user with 'One', got %d", len(result))
}
// Test pagination
filters = repository.UserFilters{Page: 1, PageSize: 2}
result, err = repo.FindAll(ctx, filters)
if err != nil {
t.Fatalf("FindAll failed: %v", err)
}
if len(result) != 2 {
t.Errorf("Expected 2 users per page, got %d", len(result))
}
}
func TestGormUserRepository_Update(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormUserRepository(db)
ctx := context.Background()
// Create test user
user := &repository.User{
Email: "update@example.com",
Name: "Original Name",
Status: "active",
}
db.Create(user)
// Update
user.Name = "Updated Name"
err := repo.Update(ctx, user)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Verify
var found repository.User
db.First(&found, user.ID)
if found.Name != "Updated Name" {
t.Errorf("Expected name 'Updated Name', got '%s'", found.Name)
}
}
func TestGormUserRepository_Delete(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormUserRepository(db)
ctx := context.Background()
// Create test user
user := &repository.User{
Email: "delete@example.com",
Name: "Delete User",
Status: "active",
}
db.Create(user)
// Delete
err := repo.Delete(ctx, user.ID)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify soft deleted
var count int64
db.Model(&repository.User{}).Where("id = ?", user.ID).Count(&count)
if count != 0 {
t.Error("Expected user to be soft deleted")
}
// Verify still exists with Unscoped
db.Unscoped().Model(&repository.User{}).Where("id = ?", user.ID).Count(&count)
if count != 1 {
t.Error("Expected user to exist in unscoped query")
}
}
// Test with context timeout
func TestGormUserRepository_ContextTimeout(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormUserRepository(db)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Let context expire
time.Sleep(10 * time.Millisecond)
_, err := repo.FindByID(ctx, 1)
if err == nil {
t.Error("Expected context timeout error")
}
}
Example 5: Best Practices Implementation
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// ===== CONFIGURATION =====
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBSSLMode string
// Pool settings
MaxIdleConns int
MaxOpenConns int
ConnMaxLifetime time.Duration
// Environment
Environment string // development, staging, production
}
func LoadConfig() Config {
return Config{
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "myapp"),
DBSSLMode: getEnv("DB_SSLMODE", "disable"),
MaxIdleConns: 10,
MaxOpenConns: 100,
ConnMaxLifetime: time.Hour,
Environment: getEnv("ENVIRONMENT", "development"),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// ===== DATABASE SETUP =====
func NewDatabase(config Config) (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
config.DBHost, config.DBPort, config.DBUser,
config.DBPassword, config.DBName, config.DBSSLMode,
)
// Configure logger based on environment
var logLevel logger.LogLevel
switch config.Environment {
case "production":
logLevel = logger.Error
case "staging":
logLevel = logger.Warn
default:
logLevel = logger.Info
}
gormLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logLevel,
IgnoreRecordNotFoundError: true,
Colorful: config.Environment == "development",
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormLogger,
PrepareStmt: true,
TranslateError: true, // enables gorm.ErrDuplicatedKey below
})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Configure connection pool
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(config.MaxIdleConns)
sqlDB.SetMaxOpenConns(config.MaxOpenConns)
sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime)
// Verify connection
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return db, nil
}
// ===== ERROR HANDLING =====
// AppError represents application errors
type AppError struct {
Code string
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Common errors
var (
ErrNotFound = &AppError{Code: "NOT_FOUND", Message: "Resource not found"}
ErrConflict = &AppError{Code: "CONFLICT", Message: "Resource already exists"}
ErrDatabase = &AppError{Code: "DATABASE_ERROR", Message: "Database operation failed"}
)
// WrapDBError wraps GORM errors into application errors
func WrapDBError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ErrConflict
}
return &AppError{Code: "DATABASE_ERROR", Message: "Database operation failed", Err: err}
}
// ===== HEALTH CHECK =====
type HealthChecker struct {
db *gorm.DB
}
func NewHealthChecker(db *gorm.DB) *HealthChecker {
return &HealthChecker{db: db}
}
func (h *HealthChecker) CheckDatabase(ctx context.Context) error {
sqlDB, err := h.db.DB()
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
}
func (h *HealthChecker) GetDBStats() map[string]interface{} {
sqlDB, _ := h.db.DB()
stats := sqlDB.Stats()
return map[string]interface{}{
"open_connections": stats.OpenConnections,
"in_use": stats.InUse,
"idle": stats.Idle,
"wait_count": stats.WaitCount,
"wait_duration": stats.WaitDuration.String(),
"max_open": stats.MaxOpenConnections,
}
}
// ===== APPLICATION STARTUP =====
func main() {
// Load configuration
config := LoadConfig()
// Initialize database
db, err := NewDatabase(config)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Health checker
health := NewHealthChecker(db)
// Verify connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := health.CheckDatabase(ctx); err != nil {
log.Fatalf("Database health check failed: %v", err)
}
log.Printf("Database connected: %+v", health.GetDBStats())
// Initialize repositories
userRepo := repository.NewGormUserRepository(db)
// Initialize services
userService := service.NewUserService(userRepo)
// Application ready
log.Println("Application started successfully")
_ = userService // Use service in handlers
}
6. Hands-On Practice (30-40 minutes)
Exercise 1: Complete Test Suite (15 minutes)
Objective: Write tests for a ProductService
Exercise 2: Add Fixtures (10 minutes)
Objective: Create test fixtures for consistent test data
Exercise 3: Error Handling (15 minutes)
Objective: Implement proper error wrapping and handling
7. Common Pitfalls and Troubleshooting (10 minutes)
Frequent Mistakes
-
Problem: Tests affecting each other Cause: Shared database state Solution: Use transactions or fresh databases per test
-
Problem: Flaky tests Cause: Timing issues with context Solution: Use reasonable timeouts, mock time if needed
-
Problem: Tests too slow Cause: Using real database for unit tests Solution: Mock repository for business logic tests
Debugging Tips
- Use table-driven tests for comprehensive coverage
- Add descriptive test names
- Use t.Helper() for test utilities
- Check test coverage with
go test -cover
8. Best Practices Summary (5 minutes)
Production Checklist
- Use environment variables for configuration
- Configure connection pooling appropriately
- Implement health checks
- Use context for timeout and cancellation
- Handle errors with proper wrapping
- Log slow queries and errors
- Use prepared statements
- Implement proper validation
- Write comprehensive tests
9. Summary and Review (5 minutes)
Key Takeaways
- Repository pattern enables testable database code
- Mocks allow testing business logic without database
- Integration tests catch issues mocks might miss
- Best practices ensure reliable production systems
Quick Review Questions
- Why use interfaces for repositories?
- When to use mocks vs test databases?
- What should health checks verify?
- How do you handle database errors properly?
For Instructors
Teaching Tips
- Start with simple mock examples
- Show test coverage reports
- Demonstrate debugging failed tests
- Compare testing approaches
Assessment Ideas
- Write test suite for given service
- Find bugs using tests
- Design testable architecture
- Review and improve existing tests
Adaptation Notes
- For beginners: Focus on integration tests
- For advanced: Cover property-based testing
- Include CI/CD integration examples