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:
- Prints numbers from 3 to 1
- Prints “Go!” at the end
- 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 pairsFor 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
}
}
}
}