Skip to main content
Go: Mocking for Testing
  1. Posts/

Go: Mocking for Testing

·970 words·5 mins·
Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

Following the Learn Go with Tests guide, exploring mocking in Go - how to test code that depends on external systems using interfaces and test doubles.

Testing External Dependencies
#

Imagine we need to test a countdown function that:

  1. Prints numbers from 3 to 1
  2. Prints “Go!” at the end
  3. Sleeps for 1 second between each print
func Countdown() {
    for i := 3; i > 0; i-- {
        fmt.Println(i)
        time.Sleep(1 * time.Second)
    }
    fmt.Println("Go!")
}

Problems:

  • Test takes 3+ seconds to run (slow)
  • Hard to test the timing behavior
  • Output goes to stdout (hard to verify)

Dependency Injection
#

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}
    Countdown(buffer)

    got := buffer.String()
    want := `3
2
1
Go!`

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

Adding sleep makes tests slow:

func Countdown(out io.Writer) {
    for i := 3; i > 0; i-- {
        fmt.Fprintln(out, i)
        time.Sleep(1 * time.Second) 
        // This makes tests slow!
    }
    fmt.Fprint(out, "Go!")
}

Solution: Extract the sleep dependency.

Create a Sleeper Interface
#

type Sleeper interface {
    Sleep()
}

Now we can inject different sleepers for production vs testing.

Test Implementation
#

A spy records how it was called:

type SpySleeper struct {
    Calls int
}

func (s *SpySleeper) Sleep() {
    s.Calls++
}

Updated test with spy:

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}
    spySleeper := &SpySleeper{}

    Countdown(buffer, spySleeper)

    got := buffer.String()
    want := `3
2
1
Go!`

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }

    if spySleeper.Calls != 3 {
        t.Errorf("not enough calls to sleeper, want 3 got %d", spySleeper.Calls)
    }
}

Production Implementation
#

type ConfigurableSleeper struct {
    duration time.Duration
    sleep    func(time.Duration)
}

func (c *ConfigurableSleeper) Sleep() {
    c.sleep(c.duration)
}

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := 3; i > 0; i-- {
        fmt.Fprintln(out, i)
        sleeper.Sleep()
    }
    fmt.Fprint(out, "Go!")
}

func main() {
    sleeper := &ConfigurableSleeper{1 * time.Second, time.Sleep}
    Countdown(os.Stdout, sleeper)
}

Types of Test Doubles
#

1. Stub
#

Returns predefined responses

Stubs return predetermined responses to specific method calls, typically ignoring any parameters or calls they weren’t designed to handle.

type StubWeatherService struct {
    temperature float64
}

func (s *StubWeatherService) GetTemperature(city string) float64 {
    return s.temperature // Always returns the same value
}

func TestTemperatureAlert(t *testing.T) {
    stub := &StubWeatherService{temperature: 35.0}
    alert := NewTemperatureAlert(stub)
    
    result := alert.CheckHeatWarning("London")
    
    if !result {
        t.Error("expected heat warning to be true")
    }
}

2. Mock
#

Verifies behavior and expectations

Mocks have built-in expectations about which methods should be called and how. They validate that the correct interactions occurred and can fail tests if unexpected calls are made.

type MockEmailService struct {
    SentEmails []Email
    ShouldFail bool
}

func (m *MockEmailService) Send(email Email) error {
    if m.ShouldFail {
        return errors.New("email service down")
    }
    m.SentEmails = append(m.SentEmails, email)
    return nil
}

func TestUserRegistration(t *testing.T) {
    mockEmail := &MockEmailService{}
    service := NewUserService(mockEmail)
    
    err := service.RegisterUser("[email protected]")
    
    if err != nil {
        t.Errorf("expected no error, got %v", err)
    }
    if len(mockEmail.SentEmails) != 1 {
        t.Errorf("expected 1 email, got %d", len(mockEmail.SentEmails))
    }
}

func TestEmailFailure(t *testing.T) {
    mockEmail := &MockEmailService{ShouldFail: true}
    service := NewUserService(mockEmail)
    
    err := service.RegisterUser("[email protected]")
    
    if err == nil {
        t.Error("expected error when email service fails")
    }
}

3. Spy
#

Records interactions

Spies capture and store information about how they’re used during tests, such as tracking call counts or recording method arguments.

type SpyLogger struct {
    Messages []string
    Levels   []LogLevel
}

func (s *SpyLogger) Log(level LogLevel, message string) {
    s.Messages = append(s.Messages, message)
    s.Levels = append(s.Levels, level)
}

func TestErrorHandling(t *testing.T) {
    spyLogger := &SpyLogger{}
    service := NewPaymentService(spyLogger)
    
    service.ProcessPayment(-100) // Invalid amount
    
    if len(spyLogger.Messages) != 1 {
        t.Errorf("expected 1 message, got %d", len(spyLogger.Messages))
    }
    
    if spyLogger.Levels[0] != ERROR {
        t.Errorf("expected ERROR level, got %v", spyLogger.Levels[0])
    }
    
    if !strings.Contains(spyLogger.Messages[0], "invalid amount") {
        t.Errorf("expected message to contain 'invalid amount', got %s", spyLogger.Messages[0])
    }
}

4. Fake
#

Simplified working implementation

Fakes contain real business logic but use simplified implementations that aren’t production-ready, like storing data in memory instead of a database.

type InMemoryUserRepository struct {
    users map[string]*User
}

func (r *InMemoryUserRepository) Save(user *User) error {
    r.users[user.Email] = user
    return nil
}

func (r *InMemoryUserRepository) FindByEmail(email string) (*User, error) {
    if user, exists := r.users[email]; exists {
        return user, nil
    }
    return nil, ErrUserNotFound
}

// Fast, isolated tests
func TestUserService(t *testing.T) {
    repo := &InMemoryUserRepository{users: make(map[string]*User)}
    service := NewUserService(repo)
    
    // Test various scenarios without database
}

When to Use Each Type
#

Test Double Use When Example
Stub Need consistent return values Weather service always returns sunny
Mock Verifying interactions Email service must be called once
Spy Recording behavior Logger captures all error messages
Fake Need working implementation In-memory database for fast tests

Key Takeaways
#

Don’t over-mock: More than 3 mocks in a test usually means fragile tests that break when implementation changes.

Test behavior, not implementation: Focus on what the code does, not how it does it. Use fakes instead of mocks when possible.

Modern Go: Iterators (Go 1.23+)
#

Go 1.23 introduced iterators, which can make our countdown code more expressive and functional in style. Instead of imperative loops, we can create custom iterators to make code more readable.

Traditional Approach vs Iterators
#

Before (imperative):

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := 3; i > 0; i-- {
        fmt.Fprintln(out, i)
        sleeper.Sleep()
    }
    fmt.Fprint(out, "Go!")
}

After (with iterators):

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := range countDownFrom(3) {
        fmt.Fprintln(out, i)
        sleeper.Sleep()
    }
    fmt.Fprint(out, "Go!")
}

Creating Custom Iterators
#

Go 1.23 iterators must match specific function signatures:

func(func() bool)           // No values
func(func(K) bool)          // Single value  
func(func(K, V) bool)       // Key-value pairs

For convenience, Go provides iter.Seq[T] which is a type alias for func(func(T) bool).

func countDownFrom(from int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := from; i > 0; i-- {
            if !yield(i) {
                return
            }
        }
    }
}