Mastering Dependency Injection and Mocking in Golang (Without Crying) ๐Ÿงช

June 15, 2025 โ€ข Written while frantically mocking dependencies at 2 AM

๐ŸŽญ Welcome to the Wonderful World of Pretending!

Ah, dependency injection and mocking in Go! It's like playing make-believe, but instead of pretending the floor is lava, you're pretending your database works! Join me on this magical journey where we'll learn to write testable code that won't make your future self want to travel back in time to slap you. ๐Ÿ”ฎ

๐Ÿงฉ The "Why Am I Even Doing This?" Section

Before we dive into the "how," let's address the existential "why" of dependency injection and mocking:

  • ๐Ÿ”„ Testing without internet (because your tests should work even when your ISP doesn't)
  • ๐Ÿš€ Testing without a real database (because who wants to wait 5 seconds per test?)
  • ๐Ÿ›ก๏ธ Testing error scenarios (like "what if the database server is having a midlife crisis?")
  • ๐Ÿงช Testing without side effects (no more "it worked on my machine" excuses)
  • ๐Ÿ”ง Swapping implementations (PostgreSQL today, MongoDB tomorrow, a series of Post-it notes next week)

๐ŸŽ“ Dependency Inversion: The Fancy Principle with a Simple Idea

Dependency Inversion is like that friend who always insists on driving instead of being driven. It's the "D" in SOLID principles, and it basically says:

"High-level modules should not depend on low-level modules. Both should depend on abstractions. Also, abstractions should not depend on details. Details should depend on abstractions." โ€” Some smart person who probably drinks a lot of coffee

In normal-human speak: "Don't directly depend on concrete implementations; depend on interfaces instead." It's like dating the idea of a person rather than an actual person. Much less messy! ๐Ÿ’”

๐Ÿ™…โ€โ™‚๏ธ The Wrong Way (AKA "Future Pain")

// This is how NOT to do it
type UserService struct {
    // Direct dependency on PostgreSQL! We're married to it forever!
    db *postgres.Connection
}

func (s *UserService) GetUser(id string) (*User, error) {
    // PostgreSQL-specific code that will haunt your dreams
    return s.db.Query("SELECT * FROM users WHERE id = $1", id)
}

// Using it:
func main() {
    db := postgres.Connect("localhost:5432")
    service := UserService{db: db}
    // Now we're stuck with Postgres until the heat death of the universe
}

๐Ÿ™†โ€โ™‚๏ธ The Right Way (AKA "Future You Will Send Thank-You Cards")

// Define an interface (the abstraction)
type UserRepository interface {
    GetUser(id string) (*User, error)
    SaveUser(user *User) error
}

// PostgreSQL implementation
type PostgresUserRepository struct {
    db *postgres.Connection
}

func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
    return r.db.Query("SELECT * FROM users WHERE id = $1", id)
}

func (r *PostgresUserRepository) SaveUser(user *User) error {
    // PostgreSQL-specific saving logic
    return nil
}

// Our service now depends on the interface, not the implementation
type UserService struct {
    repo UserRepository // Look ma, no concrete types!
}

func (s *UserService) GetUser(id string) (*User, error) {
    return s.repo.GetUser(id) // We don't care how it's implemented!
}

// Using it:
func main() {
    db := postgres.Connect("localhost:5432")
    repo := &PostgresUserRepository{db: db}
    service := UserService{repo: repo}
    // We can swap out the repo implementation anytime!
    // It's like having commitment issues, but in a good way!
}

๐Ÿ’‰ Dependency Injection: Not as Scary as It Sounds

Dependency Injection is just a fancy way of saying "pass stuff in instead of creating it inside." It's like bringing your own snacks to a movie instead of being forced to buy the overpriced theater popcorn. ๐Ÿฟ

๐ŸŒŸ Three Ways to Inject Dependencies (From Least to Most Fancy)

1. Constructor Injection (The Classic)

// Pass dependencies when creating the service
func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo: repo,
    }
}

// Usage:
repo := &PostgresUserRepository{db: db}
service := NewUserService(repo) // Dependency injected! ๐ŸŽ‰

2. Method Injection (The Occasional Visitor)

// Pass dependency just for this method call
func (s *UserService) GetUserWithSpecialRepo(id string, repo UserRepository) (*User, error) {
    return repo.GetUser(id)
}

// Usage:
specialRepo := &VIPUserRepository{}
user, err := service.GetUserWithSpecialRepo("123", specialRepo)

3. Property Injection (The Afterthought)

// Create first, inject later (like remembering to buy milk after you've already started baking)
service := &UserService{}
service.repo = &PostgresUserRepository{db: db}
// Now we can use it

๐Ÿงช Let's Build a REST API with Testable Layers!

Time to put theory into practice with a simple REST API for managing... wait for it... tacos! ๐ŸŒฎ Because everything is better with tacos.

Step 1: Define Your Domain and Interfaces

// models.go
package taco

type Taco struct {
    ID          string   `json:"id"`
    Name        string   `json:"name"`
    Ingredients []string `json:"ingredients"`
    Spiciness   int      `json:"spiciness"` // 1-5, where 5 means "breathe fire"
}

// repository.go
package taco

// TacoRepository defines how we interact with our taco storage
type TacoRepository interface {
    GetTaco(id string) (*Taco, error)
    ListTacos() ([]*Taco, error)
    SaveTaco(taco *Taco) error
    DeleteTaco(id string) error
}

// service.go
package taco

// TacoService handles taco business logic
type TacoService interface {
    GetTaco(id string) (*Taco, error)
    ListTacos() ([]*Taco, error)
    CreateTaco(taco *Taco) error
    UpdateTaco(taco *Taco) error
    DeleteTaco(id string) error
}

Step 2: Implement the Service Layer

// service_impl.go
package taco

import (
    "errors"
    "strings"
)

type tacoService struct {
    repo TacoRepository
}

// NewTacoService creates a new taco service with the given repository
func NewTacoService(repo TacoRepository) TacoService {
    return &tacoService{
        repo: repo,
    }
}

func (s *tacoService) GetTaco(id string) (*Taco, error) {
    return s.repo.GetTaco(id)
}

func (s *tacoService) ListTacos() ([]*Taco, error) {
    return s.repo.ListTacos()
}

func (s *tacoService) CreateTaco(taco *Taco) error {
    // Business logic: Validate taco before saving
    if taco.Name == "" {
        return errors.New("taco needs a name, like a sad puppy needs love")
    }
    if len(taco.Ingredients) == 0 {
        return errors.New("a taco without ingredients is just a sad tortilla")
    }
    if taco.Spiciness < 1 || taco.Spiciness > 5 {
        return errors.New("spiciness must be between 1-5, we don't want lawsuits")
    }
    
    return s.repo.SaveTaco(taco)
}

func (s *tacoService) UpdateTaco(taco *Taco) error {
    // Check if taco exists first
    existing, err := s.repo.GetTaco(taco.ID)
    if err != nil {
        return err
    }
    if existing == nil {
        return errors.New("cannot update the ghost of a taco past")
    }
    
    return s.repo.SaveTaco(taco)
}

func (s *tacoService) DeleteTaco(id string) error {
    return s.repo.DeleteTaco(id)
}

Step 3: Implement a Real Repository

// postgres_repository.go
package taco

import (
    "database/sql"
    _ "github.com/lib/pq"
)

type postgresRepository struct {
    db *sql.DB
}

// NewPostgresRepository creates a new PostgreSQL-backed taco repository
func NewPostgresRepository(connectionString string) (TacoRepository, error) {
    db, err := sql.Open("postgres", connectionString)
    if err != nil {
        return nil, err
    }
    
    // Test the connection
    if err := db.Ping(); err != nil {
        return nil, err
    }
    
    return &postgresRepository{db: db}, nil
}

func (r *postgresRepository) GetTaco(id string) (*Taco, error) {
    // Real database query here
    // SELECT id, name, ingredients, spiciness FROM tacos WHERE id = $1
    // ...
    return &Taco{ID: id, Name: "Database Taco"}, nil // Simplified for brevity
}

// Other methods implemented similarly...

Step 4: Create the REST API Handler

// handler.go
package taco

import (
    "encoding/json"
    "net/http"
    "github.com/gorilla/mux"
)

type TacoHandler struct {
    service TacoService
}

func NewTacoHandler(service TacoService) *TacoHandler {
    return &TacoHandler{
        service: service,
    }
}

func (h *TacoHandler) RegisterRoutes(router *mux.Router) {
    router.HandleFunc("/tacos", h.ListTacos).Methods("GET")
    router.HandleFunc("/tacos/{id}", h.GetTaco).Methods("GET")
    router.HandleFunc("/tacos", h.CreateTaco).Methods("POST")
    router.HandleFunc("/tacos/{id}", h.UpdateTaco).Methods("PUT")
    router.HandleFunc("/tacos/{id}", h.DeleteTaco).Methods("DELETE")
}

func (h *TacoHandler) GetTaco(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]
    
    taco, err := h.service.GetTaco(id)
    if err != nil {
        http.Error(w, "Failed to get taco: "+err.Error(), http.StatusInternalServerError)
        return
    }
    
    if taco == nil {
        http.Error(w, "Taco not found", http.StatusNotFound)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(taco)
}

// Other handler methods implemented similarly...

Step 5: Wire Everything Together

// main.go
package main

import (
    "log"
    "net/http"
    "github.com/gorilla/mux"
    "yourproject/taco"
)

func main() {
    // Create the repository
    repo, err := taco.NewPostgresRepository("postgres://user:pass@localhost/tacos")
    if err != nil {
        log.Fatalf("Failed to create repository: %v", err)
    }
    
    // Create the service with the repository
    service := taco.NewTacoService(repo)
    
    // Create the handler with the service
    handler := taco.NewTacoHandler(service)
    
    // Set up the router
    router := mux.NewRouter()
    handler.RegisterRoutes(router)
    
    // Start the server
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", router))
}

๐ŸŽญ Time to Mock Around and Find Out!

Now for the fun part: testing all this code without touching a real database! It's like practicing karate moves without actually hitting anyone. ๐Ÿฅ‹

Creating a Mock Repository

// mock_repository.go
package taco

import "errors"

// MockTacoRepository is a fake implementation for testing
type MockTacoRepository struct {
    tacos map[string]*Taco
    // We can add these to simulate failures
    ShouldFailOnGet    bool
    ShouldFailOnList   bool
    ShouldFailOnSave   bool
    ShouldFailOnDelete bool
}

// NewMockTacoRepository creates a new in-memory taco repository
func NewMockTacoRepository() *MockTacoRepository {
    return &MockTacoRepository{
        tacos: make(map[string]*Taco),
    }
}

func (r *MockTacoRepository) GetTaco(id string) (*Taco, error) {
    if r.ShouldFailOnGet {
        return nil, errors.New("simulated database explosion ๐Ÿ’ฅ")
    }
    
    taco, exists := r.tacos[id]
    if !exists {
        return nil, nil // Not found, but not an error
    }
    return taco, nil
}

func (r *MockTacoRepository) ListTacos() ([]*Taco, error) {
    if r.ShouldFailOnList {
        return nil, errors.New("simulated network outage ๐Ÿ”Œ")
    }
    
    tacos := make([]*Taco, 0, len(r.tacos))
    for _, taco := range r.tacos {
        tacos = append(tacos, taco)
    }
    return tacos, nil
}

func (r *MockTacoRepository) SaveTaco(taco *Taco) error {
    if r.ShouldFailOnSave {
        return errors.New("simulated disk full error ๐Ÿ’พ")
    }
    
    r.tacos[taco.ID] = taco
    return nil
}

func (r *MockTacoRepository) DeleteTaco(id string) error {
    if r.ShouldFailOnDelete {
        return errors.New("simulated permission denied ๐Ÿ”’")
    }
    
    delete(r.tacos, id)
    return nil
}

Testing the Service with Mocks

// service_test.go
package taco

import (
    "testing"
)

func TestCreateTaco(t *testing.T) {
    // Arrange: Set up our mock and service
    mockRepo := NewMockTacoRepository()
    service := NewTacoService(mockRepo)
    
    // Act: Try to create a valid taco
    taco := &Taco{
        ID:          "taco1",
        Name:        "Super Taco",
        Ingredients: []string{"Beef", "Cheese", "Lettuce"},
        Spiciness:   3,
    }
    err := service.CreateTaco(taco)
    
    // Assert: Should succeed
    if err != nil {
        t.Errorf("Expected no error, got: %v", err)
    }
    
    // Verify the taco was saved in our mock repo
    savedTaco, _ := mockRepo.GetTaco("taco1")
    if savedTaco == nil {
        t.Error("Taco was not saved to repository")
    }
}

func TestCreateTacoValidation(t *testing.T) {
    // Arrange
    mockRepo := NewMockTacoRepository()
    service := NewTacoService(mockRepo)
    
    // Test cases
    testCases := []struct {
        name        string
        taco        *Taco
        shouldError bool
    }{
        {
            name: "No name",
            taco: &Taco{
                ID:          "taco2",
                Name:        "", // Invalid: empty name
                Ingredients: []string{"Beef"},
                Spiciness:   3,
            },
            shouldError: true,
        },
        {
            name: "No ingredients",
            taco: &Taco{
                ID:          "taco3",
                Name:        "Empty Taco",
                Ingredients: []string{}, // Invalid: no ingredients
                Spiciness:   3,
            },
            shouldError: true,
        },
        {
            name: "Invalid spiciness",
            taco: &Taco{
                ID:          "taco4",
                Name:        "Super Hot",
                Ingredients: []string{"Ghost Pepper"},
                Spiciness:   10, // Invalid: too spicy!
            },
            shouldError: true,
        },
    }
    
    // Act & Assert
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            err := service.CreateTaco(tc.taco)
            
            if tc.shouldError && err == nil {
                t.Error("Expected validation error but got none")
            }
            
            if !tc.shouldError && err != nil {
                t.Errorf("Expected no error but got: %v", err)
            }
        })
    }
}

func TestGetTacoWhenRepositoryFails(t *testing.T) {
    // Arrange: Create a failing mock repository
    mockRepo := NewMockTacoRepository()
    mockRepo.ShouldFailOnGet = true // Simulate database failure
    service := NewTacoService(mockRepo)
    
    // Act: Try to get a taco
    _, err := service.GetTaco("any-id")
    
    // Assert: Should return the error from repository
    if err == nil {
        t.Error("Expected error from repository failure, got nil")
    }
}

Testing the HTTP Handler

// handler_test.go
package taco

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
    
    "github.com/gorilla/mux"
)

// MockTacoService implements TacoService for testing handlers
type MockTacoService struct {
    tacos map[string]*Taco
    // Control test behavior
    ShouldFailOnGet    bool
    ShouldFailOnCreate bool
}

func NewMockTacoService() *MockTacoService {
    return &MockTacoService{
        tacos: make(map[string]*Taco),
    }
}

// Implement all TacoService methods...
func (s *MockTacoService) GetTaco(id string) (*Taco, error) {
    if s.ShouldFailOnGet {
        return nil, errors.New("simulated service error")
    }
    return s.tacos[id], nil
}

// Other methods implemented similarly...

func TestGetTacoHandler(t *testing.T) {
    // Arrange
    mockService := NewMockTacoService()
    // Add a test taco
    mockService.tacos["taco123"] = &Taco{
        ID:          "taco123",
        Name:        "Test Taco",
        Ingredients: []string{"Test Beef", "Test Cheese"},
        Spiciness:   4,
    }
    
    handler := NewTacoHandler(mockService)
    
    // Create a test request
    req, _ := http.NewRequest("GET", "/tacos/taco123", nil)
    
    // Create a response recorder
    rr := httptest.NewRecorder()
    
    // Create the router and register the route
    router := mux.NewRouter()
    router.HandleFunc("/tacos/{id}", handler.GetTaco).Methods("GET")
    
    // Act: Serve the request
    router.ServeHTTP(rr, req)
    
    // Assert: Check the response
    if status := rr.Code; status != http.StatusOK {
        t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
    }
    
    // Check the response body
    var responseTaco Taco
    err := json.Unmarshal(rr.Body.Bytes(), &responseTaco)
    if err != nil {
        t.Errorf("Failed to parse response JSON: %v", err)
    }
    
    if responseTaco.ID != "taco123" || responseTaco.Name != "Test Taco" {
        t.Errorf("Handler returned unexpected taco: %+v", responseTaco)
    }
}

func TestGetTacoNotFound(t *testing.T) {
    // Arrange
    mockService := NewMockTacoService()
    handler := NewTacoHandler(mockService)
    
    // Create a test request for a non-existent taco
    req, _ := http.NewRequest("GET", "/tacos/nonexistent", nil)
    rr := httptest.NewRecorder()
    
    router := mux.NewRouter()
    router.HandleFunc("/tacos/{id}", handler.GetTaco).Methods("GET")
    
    // Act
    router.ServeHTTP(rr, req)
    
    // Assert: Should return 404 Not Found
    if status := rr.Code; status != http.StatusNotFound {
        t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusNotFound)
    }
}

๐ŸŠโ€โ™‚๏ธ Testing with a Real Database (For the Brave)

Sometimes you need to test with a real database to make sure your SQL isn't just a creative writing exercise. Here's how to do it without making a mess! ๐Ÿงน

Integration Tests with a Test Database

// integration_test.go
package taco

import (
    "database/sql"
    "log"
    "os"
    "testing"
    
    _ "github.com/lib/pq"
)

var testDB *sql.DB

// Setup function that runs before tests
func TestMain(m *testing.M) {
    // Get connection string from environment or use default test DB
    connStr := os.Getenv("TEST_DB_CONNECTION")
    if connStr == "" {
        connStr = "postgres://postgres:postgres@localhost/tacos_test"
    }
    
    var err error
    testDB, err = sql.Open("postgres", connStr)
    if err != nil {
        log.Fatalf("Failed to connect to test database: %v", err)
    }
    
    // Create tables and prepare test database
    setupTestDB()
    
    // Run the tests
    code := m.Run()
    
    // Clean up after tests
    cleanupTestDB()
    testDB.Close()
    
    os.Exit(code)
}

func setupTestDB() {
    // Create tables
    _, err := testDB.Exec(`
        CREATE TABLE IF NOT EXISTS tacos (
            id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            ingredients TEXT[] NOT NULL,
            spiciness INTEGER NOT NULL
        )
    `)
    if err != nil {
        log.Fatalf("Failed to create test tables: %v", err)
    }
}

func cleanupTestDB() {
    // Clean up all data
    _, err := testDB.Exec("DELETE FROM tacos")
    if err != nil {
        log.Printf("Warning: Failed to clean test database: %v", err)
    }
}

// Helper to get a clean repository for each test
func getTestRepository() TacoRepository {
    // Clean any existing data first
    _, err := testDB.Exec("DELETE FROM tacos")
    if err != nil {
        log.Fatalf("Failed to clean test data: %v", err)
    }
    
    return &postgresRepository{db: testDB}
}

func TestRepositorySaveTaco(t *testing.T) {
    // Skip if we're running short tests
    if testing.Short() {
        t.Skip("Skipping integration test in short mode")
    }
    
    // Arrange
    repo := getTestRepository()
    taco := &Taco{
        ID:          "integration1",
        Name:        "Integration Test Taco",
        Ingredients: []string{"Database", "SQL", "Integration"},
        Spiciness:   5,
    }
    
    // Act
    err := repo.SaveTaco(taco)
    
    // Assert
    if err != nil {
        t.Fatalf("Failed to save taco: %v", err)
    }
    
    // Verify we can retrieve it
    retrieved, err := repo.GetTaco("integration1")
    if err != nil {
        t.Fatalf("Failed to get saved taco: %v", err)
    }
    
    if retrieved == nil {
        t.Fatal("Retrieved taco is nil")
    }
    
    if retrieved.Name != "Integration Test Taco" {
        t.Errorf("Retrieved incorrect taco name: got %s, want %s", 
                 retrieved.Name, "Integration Test Taco")
    }
}

๐Ÿง  Pro Tips for Dependency Injection and Testing

  1. Keep interfaces small - The smaller the interface, the easier to mock. Don't make your interfaces the size of a novel! ๐Ÿ“š
  2. Use table-driven tests - Test multiple scenarios without copy-pasting code like a madman. Your future carpal tunnel thanks you! ๐Ÿ™
  3. Test behavior, not implementation - Don't test that a function calls another function; test that it does what it's supposed to do! ๐ŸŽฏ
  4. Use constructor injection - It makes dependencies explicit and impossible to forget. Like putting your keys on a giant keychain! ๐Ÿ”‘
  5. Consider using a mocking library - For complex interfaces, libraries like gomock or testify/mock can save you time. Work smarter, not harder! ๐Ÿง 

๐ŸŽฎ Bonus: Quick Cheat Sheet for Testing

Testing Goal Approach When to Use
Test business logic Unit tests with mocks Most of the time (80%)
Test database queries Integration tests with test DB When SQL matters (15%)
Test entire system End-to-end tests Critical paths only (5%)
Test HTTP handlers httptest package with mock services For all API endpoints
Test error handling Mocks with forced errors For every possible failure point

๐ŸŽญ The Grand Finale: A Complete Testing Example

Let's put it all together with a complete example that shows the full testing lifecycle of our taco service. This is where all our hard work pays off! ๐Ÿ†

// full_test.go
package taco_test // Note: using _test suffix to test from outside the package

import (
    "testing"
    
    "yourproject/taco" // Import your actual package
)

func TestFullTacoLifecycle(t *testing.T) {
    // ===== ARRANGE =====
    // Create our mock repository
    mockRepo := taco.NewMockTacoRepository()
    
    // Create the service with our mock
    service := taco.NewTacoService(mockRepo)
    
    // ===== ACT & ASSERT: CREATE =====
    // Create a new taco
    newTaco := &taco.Taco{
        ID:          "lifecycle-taco",
        Name:        "Lifecycle Taco",
        Ingredients: []string{"Code", "Tests", "Mocks", "Interfaces"},
        Spiciness:   4,
    }
    
    err := service.CreateTaco(newTaco)
    if err != nil {
        t.Fatalf("Failed to create taco: %v", err)
    }
    
    // ===== ACT & ASSERT: RETRIEVE =====
    // Get the taco we just created
    retrieved, err := service.GetTaco("lifecycle-taco")
    if err != nil {
        t.Fatalf("Failed to get taco: %v", err)
    }
    
    if retrieved == nil {
        t.Fatal("Retrieved taco is nil")
    }
    
    if retrieved.Name != "Lifecycle Taco" {
        t.Errorf("Retrieved incorrect name: got %s, want %s", 
                 retrieved.Name, "Lifecycle Taco")
    }
    
    // ===== ACT & ASSERT: UPDATE =====
    // Update the taco
    retrieved.Name = "Updated Lifecycle Taco"
    retrieved.Spiciness = 5
    
    err = service.UpdateTaco(retrieved)
    if err != nil {
        t.Fatalf("Failed to update taco: %v", err)
    }
    
    // Get it again to verify the update
    updated, err := service.GetTaco("lifecycle-taco")
    if err != nil {
        t.Fatalf("Failed to get updated taco: %v", err)
    }
    
    if updated.Name != "Updated Lifecycle Taco" {
        t.Errorf("Update failed: got name %s, want %s", 
                 updated.Name, "Updated Lifecycle Taco")
    }
    
    if updated.Spiciness != 5 {
        t.Errorf("Update failed: got spiciness %d, want %d", 
                 updated.Spiciness, 5)
    }
    
    // ===== ACT & ASSERT: LIST =====
    // List all tacos
    tacos, err := service.ListTacos()
    if err != nil {
        t.Fatalf("Failed to list tacos: %v", err)
    }
    
    if len(tacos) != 1 {
        t.Errorf("Expected 1 taco, got %d", len(tacos))
    }
    
    // ===== ACT & ASSERT: DELETE =====
    // Delete the taco
    err = service.DeleteTaco("lifecycle-taco")
    if err != nil {
        t.Fatalf("Failed to delete taco: %v", err)
    }
    
    // Verify it's gone
    deleted, err := service.GetTaco("lifecycle-taco")
    if err != nil {
        t.Fatalf("Error checking deleted taco: %v", err)
    }
    
    if deleted != nil {
        t.Error("Taco wasn't deleted properly")
    }
    
    // List again to make sure it's empty
    tacos, err = service.ListTacos()
    if err != nil {
        t.Fatalf("Failed to list tacos after delete: %v", err)
    }
    
    if len(tacos) != 0 {
        t.Errorf("Expected 0 tacos after delete, got %d", len(tacos))
    }
}

๐Ÿง™โ€โ™‚๏ธ Testing Error Scenarios Like a Wizard

The true test of a robust system is how it handles failures. Let's see how to test those pesky error cases that keep you up at night! ๐ŸŒ™

// error_test.go
package taco

import (
    "testing"
)

func TestErrorScenarios(t *testing.T) {
    // Create a repository that will fail on command
    mockRepo := NewMockTacoRepository()
    service := NewTacoService(mockRepo)
    
    // Test cases for different failure scenarios
    testCases := []struct {
        name        string
        setup       func() // Function to set up the failure scenario
        operation   func() error // The operation to test
        errorExpected bool
    }{
        {
            name: "GetTaco fails when repository fails",
            setup: func() {
                mockRepo.ShouldFailOnGet = true
            },
            operation: func() error {
                _, err := service.GetTaco("any-id")
                return err
            },
            errorExpected: true,
        },
        {
            name: "ListTacos fails when repository fails",
            setup: func() {
                mockRepo.ShouldFailOnList = true
            },
            operation: func() error {
                _, err := service.ListTacos()
                return err
            },
            errorExpected: true,
        },
        {
            name: "CreateTaco fails when repository fails",
            setup: func() {
                mockRepo.ShouldFailOnSave = true
            },
            operation: func() error {
                taco := &Taco{
                    ID:          "error-taco",
                    Name:        "Error Taco",
                    Ingredients: []string{"Error", "Exception", "Failure"},
                    Spiciness:   3,
                }
                return service.CreateTaco(taco)
            },
            errorExpected: true,
        },
        {
            name: "UpdateTaco fails when taco doesn't exist",
            setup: func() {
                // Don't add any tacos to the repository
                mockRepo.ShouldFailOnGet = false
            },
            operation: func() error {
                taco := &Taco{
                    ID:          "nonexistent",
                    Name:        "Nonexistent Taco",
                    Ingredients: []string{"Void", "Emptiness"},
                    Spiciness:   1,
                }
                return service.UpdateTaco(taco)
            },
            errorExpected: true,
        },
    }
    
    // Run all the test cases
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            // Reset the mock for each test
            mockRepo.ShouldFailOnGet = false
            mockRepo.ShouldFailOnList = false
            mockRepo.ShouldFailOnSave = false
            mockRepo.ShouldFailOnDelete = false
            
            // Set up the specific failure scenario
            tc.setup()
            
            // Run the operation
            err := tc.operation()
            
            // Check if we got the expected error behavior
            if tc.errorExpected && err == nil {
                t.Error("Expected an error but got nil")
            }
            
            if !tc.errorExpected && err != nil {
                t.Errorf("Expected no error but got: %v", err)
            }
        })
    }
}

๐ŸŽ“ Graduation: You're Now a Dependency Injection Master!

Congratulations! You've survived the wild world of dependency injection and mocking in Go! You can now:

  • ๐Ÿ—๏ธ Design code with proper dependency inversion
  • ๐Ÿ’‰ Inject dependencies like a professional
  • ๐ŸŽญ Create mocks that would make Broadway jealous
  • ๐Ÿงช Test your code without relying on external systems
  • ๐Ÿ” Simulate errors without actually breaking things
  • ๐Ÿš€ Build robust, testable REST APIs

๐Ÿง  Key Takeaways for the Road

  1. Interfaces are your friends - They create the seams that make your code testable.
  2. Interfaces are your friends - They create the seams that make your code testable.
  3. Dependency injection isn't scary - It's just passing things in instead of creating them inside.
  4. Mocks don't have to be complex - A simple struct that implements your interface is often enough.
  5. Test both happy and sad paths - Make sure your code handles errors gracefully.
  6. Integration tests complement unit tests - They verify that your code works with real dependencies.

๐Ÿš€ Where to Go From Here

Now that you're a dependency injection and mocking wizard, here are some next steps to level up even further:

  • Explore mocking libraries like gomock and testify
  • Learn about wire generation with Google's Wire
  • Dive into behavior-driven development (BDD) with Ginkgo
  • Check out dependency injection frameworks like Uber's Dig or Wire
  • Practice writing tests first (TDD) for your next project

๐ŸŽฌ Final Thoughts: The Testing Mindset

Remember, the goal isn't just to have testsโ€”it's to have testable code. Dependency injection and good interface design are tools that help you write code that's easier to test, maintain, and extend.

When you design with testing in mind from the start, you'll naturally create more modular, decoupled systems. Your future self (and teammates) will thank you when they need to understand, modify, or extend your code.

Now go forth and inject dependencies like a pro! ๐Ÿ’‰โœจ

P.S. If your tests are still failing after following this guide, have you tried turning your computer off and on again? Works 60% of the time, every time! ๐Ÿ˜‰

#Golang #Testing #Mocking #DependencyInjection #Interfaces #RESTAPI #TDD #GoTesting #SolidPrinciples