Skip to content
Published on

Go Testing Complete Guide: Unit Tests, Mocks, and Stubs

Authors

Introduction

Software quality is proven through tests. Go ships with powerful testing tools built into the language itself — the testing package and go test command alone are enough to write production-grade tests.

This guide covers practical, immediately applicable patterns from basic unit tests to dependency isolation with mocks and stubs, all the way through integration testing.


1. Go Testing Basics

1.1 The testing Package

Go tests are written in _test.go files, and test functions start with the Test prefix.

// calculator.go
package calculator

import "fmt"

func Add(a, b int) int    { return a + b }
func Sub(a, b int) int    { return a - b }
func Mul(a, b int) int    { return a * b }
func Div(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}
// calculator_test.go
package calculator

import (
    "testing"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

// t.Fatal: stops execution of the current test immediately
func TestDiv(t *testing.T) {
    result, err := Div(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 5.0 {
        t.Errorf("Div(10, 2) = %f; want 5.0", result)
    }

    // Test division by zero
    _, err = Div(10, 0)
    if err == nil {
        t.Error("Div by zero should return an error")
    }
}

// t.Helper(): marks a function as a test helper so error lines point to the caller
func assertEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMul(t *testing.T) {
    assertEqual(t, Mul(3, 4), 12)
    assertEqual(t, Mul(0, 100), 0)
    assertEqual(t, Mul(-2, 5), -10)
}

1.2 Running Tests

# Test current package
go test

# Test all packages
go test ./...

# Verbose output
go test -v

# Run only specific tests (regex)
go test -run TestAdd
go test -run TestDiv

# Coverage
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out    # open in browser
go tool cover -func=coverage.out    # per-function coverage

# Set timeout
go test -timeout 30s

# Detect race conditions
go test -race

# Disable caching
go test -count=1 ./...

1.3 TestMain

package mypackage_test

import (
    "fmt"
    "os"
    "testing"
)

var testDB *Database

// TestMain: runs setup and teardown around the entire test suite
func TestMain(m *testing.M) {
    // Setup
    fmt.Println("Setting up test environment")
    testDB = setupTestDatabase()

    // Run tests
    code := m.Run()

    // Teardown
    testDB.Close()
    fmt.Println("Test environment cleaned up")

    os.Exit(code)
}

2. Table-Driven Tests

The most idiomatic testing pattern in Go.

package calculator_test

import (
    "fmt"
    "testing"

    "github.com/example/calculator"
)

func TestAdd_TableDriven(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive + positive", 2, 3, 5},
        {"positive + negative", 5, -3, 2},
        {"negative + negative", -4, -6, -10},
        {"zero case", 0, 0, 0},
        {"large numbers", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := calculator.Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

// Table test with error cases
func TestDiv_TableDriven(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"normal division", 10, 2, 5.0, false},
        {"fractional result", 7, 2, 3.5, false},
        {"division by zero", 10, 0, 0, true},
        {"negative dividend", -10, 2, -5.0, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := calculator.Div(tt.a, tt.b)
            if tt.expectErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Div(%.1f, %.1f) = %.1f; want %.1f",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

// Per-subtest setup and teardown
func TestUserService_TableDriven(t *testing.T) {
    tests := []struct {
        name    string
        userID  int
        wantErr bool
    }{
        {"valid user", 1, false},
        {"user not found", 999, true},
        {"negative ID", -1, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Per-subtest setup
            svc := NewTestUserService()
            defer svc.Cleanup()

            user, err := svc.GetUser(tt.userID)
            if (err != nil) != tt.wantErr {
                t.Errorf("GetUser(%d) error = %v, wantErr %v",
                    tt.userID, err, tt.wantErr)
                return
            }
            if !tt.wantErr {
                fmt.Printf("user: %+v\n", user)
            }
        })
    }
}

3. The testify Library

testify is the most widely used testing library in Go.

go get github.com/stretchr/testify

3.1 assert vs require

package mypackage_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)

    // assert: test continues even on failure
    assert.Equal(t, 5, result, "2+3 should equal 5")
    assert.NotEqual(t, 6, result)
    assert.True(t, result > 0)
    assert.False(t, result < 0)

    // require: test stops immediately on failure
    require.NotNil(t, result)
    require.Equal(t, 5, result)
}

func TestCollections(t *testing.T) {
    nums := []int{1, 2, 3, 4, 5}

    assert.Contains(t, nums, 3)
    assert.NotContains(t, nums, 10)
    assert.Len(t, nums, 5)
    assert.Empty(t, []int{})
    assert.NotEmpty(t, nums)

    m := map[string]int{"a": 1, "b": 2}
    assert.Equal(t, 1, m["a"])
}

func TestErrors(t *testing.T) {
    _, err := Div(10, 0)
    assert.Error(t, err)
    assert.EqualError(t, err, "division by zero")

    result, err := Div(10, 2)
    assert.NoError(t, err)
    assert.Equal(t, 5.0, result)
}

func TestPanic(t *testing.T) {
    assert.Panics(t, func() {
        panic("test panic")
    })
    assert.NotPanics(t, func() {
        _ = Add(1, 2)
    })
}

3.2 assert.JSONEq and HTTP Assertions

package mypackage_test

import (
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestJSONResponse(t *testing.T) {
    handler := func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"name":"Alice","age":30}`))
    }

    req := httptest.NewRequest(http.MethodGet, "/user", nil)
    rec := httptest.NewRecorder()
    handler(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

    // JSON comparison regardless of key order
    expected := `{"age": 30, "name": "Alice"}`
    assert.JSONEq(t, expected, rec.Body.String())
}

3.3 testify/suite

package mypackage_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/suite"
)

type UserServiceSuite struct {
    suite.Suite
    service *UserService
    db      *TestDB
}

// Runs once before the entire suite
func (s *UserServiceSuite) SetupSuite() {
    s.db = NewTestDB()
}

// Runs before each test
func (s *UserServiceSuite) SetupTest() {
    s.service = NewUserService(s.db)
    s.db.Seed()
}

// Runs after each test
func (s *UserServiceSuite) TearDownTest() {
    s.db.Clean()
}

// Runs once after the entire suite
func (s *UserServiceSuite) TearDownSuite() {
    s.db.Close()
}

func (s *UserServiceSuite) TestGetUser() {
    user, err := s.service.GetUser(1)
    s.NoError(err)
    s.Equal("Alice", user.Name)
}

func (s *UserServiceSuite) TestCreateUser() {
    user, err := s.service.CreateUser("Bob", "bob@example.com")
    s.NoError(err)
    s.NotZero(user.ID)
    s.Equal("Bob", user.Name)
}

func (s *UserServiceSuite) TestDeleteUser_NotFound() {
    err := s.service.DeleteUser(9999)
    s.Error(err)
    assert.Contains(s.T(), err.Error(), "not found")
}

// Register the suite with the Go test runner
func TestUserServiceSuite(t *testing.T) {
    suite.Run(t, new(UserServiceSuite))
}

4. Interface-Based Mocks

4.1 Dependency Injection via Interfaces

// user_repository.go
package user

import (
    "context"
    "fmt"
)

type User struct {
    ID    int
    Name  string
    Email string
}

// Interface definition (the key to dependency injection)
type Repository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context) ([]*User, error)
}

type EmailSender interface {
    Send(to, subject, body string) error
}

// Service depends on interfaces, not concrete implementations
type Service struct {
    repo  Repository
    email EmailSender
}

func NewService(repo Repository, email EmailSender) *Service {
    return &Service{repo: repo, email: email}
}

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
    return s.repo.FindByID(ctx, id)
}

func (s *Service) CreateUser(ctx context.Context, name, email string) (*User, error) {
    user := &User{Name: name, Email: email}
    if err := s.repo.Save(ctx, user); err != nil {
        return nil, fmt.Errorf("saving user: %w", err)
    }
    // Send welcome email (failure doesn't affect user creation)
    _ = s.email.Send(email, "Welcome!", "Thank you for signing up!")
    return user, nil
}

4.2 Manual Mock Implementation

// Can be defined directly in test files
package user_test

import (
    "context"
    "fmt"
    "github.com/example/user"
)

type MockRepository struct {
    users   map[int]*user.User
    saveErr error
    findErr error
}

func NewMockRepository() *MockRepository {
    return &MockRepository{users: make(map[int]*user.User)}
}

func (m *MockRepository) FindByID(ctx context.Context, id int) (*user.User, error) {
    if m.findErr != nil {
        return nil, m.findErr
    }
    u, ok := m.users[id]
    if !ok {
        return nil, fmt.Errorf("user %d not found", id)
    }
    return u, nil
}

func (m *MockRepository) Save(ctx context.Context, u *user.User) error {
    if m.saveErr != nil {
        return m.saveErr
    }
    if u.ID == 0 {
        u.ID = len(m.users) + 1
    }
    m.users[u.ID] = u
    return nil
}

func (m *MockRepository) Delete(ctx context.Context, id int) error {
    delete(m.users, id)
    return nil
}

func (m *MockRepository) List(ctx context.Context) ([]*user.User, error) {
    users := make([]*user.User, 0, len(m.users))
    for _, u := range m.users {
        users = append(users, u)
    }
    return users, nil
}

type MockEmailSender struct {
    SentEmails []struct{ To, Subject, Body string }
    SendErr    error
}

func (m *MockEmailSender) Send(to, subject, body string) error {
    if m.SendErr != nil {
        return m.SendErr
    }
    m.SentEmails = append(m.SentEmails, struct{ To, Subject, Body string }{to, subject, body})
    return nil
}

5. Using gomock

5.1 Installing gomock and Generating Mocks

go install go.uber.org/mock/mockgen@latest
go get go.uber.org/mock/gomock

Add a go:generate directive to your interface file:

// repository.go
package user

//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go -package=mocks
type Repository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context) ([]*User, error)
}
# Generate mocks
go generate ./...

5.2 Writing Tests with gomock

package user_test

import (
    "context"
    "fmt"
    "testing"

    "github.com/example/user"
    "github.com/example/user/mocks"
    "go.uber.org/mock/gomock"
    "github.com/stretchr/testify/assert"
)

func TestGetUser_WithGomock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    mockEmail := mocks.NewMockEmailSender(ctrl)

    expectedUser := &user.User{ID: 1, Name: "Alice", Email: "alice@example.com"}

    // EXPECT: set up call expectations
    mockRepo.EXPECT().
        FindByID(gomock.Any(), 1).
        Return(expectedUser, nil).
        Times(1)

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expectedUser, got)
}

func TestCreateUser_WithGomock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    mockEmail := mocks.NewMockEmailSender(ctrl)

    // Save must be called exactly once
    mockRepo.EXPECT().
        Save(gomock.Any(), gomock.Any()).
        DoAndReturn(func(ctx context.Context, u *user.User) error {
            u.ID = 42 // simulate ID assignment
            return nil
        }).
        Times(1)

    // Send can be called zero or more times
    mockEmail.EXPECT().
        Send(gomock.Any(), gomock.Any(), gomock.Any()).
        Return(nil).
        AnyTimes()

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")

    assert.NoError(t, err)
    assert.Equal(t, 42, got.ID)
    assert.Equal(t, "Bob", got.Name)
}

// Using specific argument matchers
func TestFindByID_SpecificArgs(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)

    // Exact argument matching
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Eq(5)).
        Return(nil, fmt.Errorf("not found")).
        Times(1)

    // Custom matcher
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Not(gomock.Eq(0))).
        Return(&user.User{ID: 10}, nil).
        AnyTimes()
}

6. Using testify/mock

6.1 Implementing Mocks with testify/mock

package user_test

import (
    "context"
    "testing"

    "github.com/example/user"
    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/assert"
)

// Mock struct embedding mock.Mock
type MockUserRepo struct {
    mock.Mock
}

func (m *MockUserRepo) FindByID(ctx context.Context, id int) (*user.User, error) {
    args := m.Called(ctx, id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*user.User), args.Error(1)
}

func (m *MockUserRepo) Save(ctx context.Context, u *user.User) error {
    args := m.Called(ctx, u)
    return args.Error(0)
}

func (m *MockUserRepo) Delete(ctx context.Context, id int) error {
    args := m.Called(ctx, id)
    return args.Error(0)
}

func (m *MockUserRepo) List(ctx context.Context) ([]*user.User, error) {
    args := m.Called(ctx)
    return args.Get(0).([]*user.User), args.Error(1)
}

func TestGetUser_Testify(t *testing.T) {
    mockRepo := new(MockUserRepo)
    mockEmail := new(MockEmailSender)

    expectedUser := &user.User{ID: 1, Name: "Alice"}

    // Use On() to configure behavior
    mockRepo.On("FindByID", mock.Anything, 1).
        Return(expectedUser, nil)

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expectedUser, got)

    // Verify all expectations were met
    mockRepo.AssertExpectations(t)
}

func TestCreateUser_Testify(t *testing.T) {
    mockRepo := new(MockUserRepo)
    mockEmail := new(MockEmailSender)

    // Use mock.MatchedBy for custom matchers
    mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(u *user.User) bool {
        return u.Name == "Bob"
    })).Return(nil)

    mockEmail.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(nil)

    svc := user.NewService(mockRepo, mockEmail)
    _, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")

    assert.NoError(t, err)
    mockRepo.AssertNumberOfCalls(t, "Save", 1)
    mockEmail.AssertNumberOfCalls(t, "Send", 1)
}

7. HTTP Mock Server (httptest)

7.1 httptest.NewRecorder

package handler_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestUserHandler_GetAll(t *testing.T) {
    store := NewInMemoryStore()
    store.Add(&User{ID: 1, Name: "Alice"})
    store.Add(&User{ID: 2, Name: "Bob"})

    handler := NewUserHandler(store)

    req := httptest.NewRequest(http.MethodGet, "/users", nil)
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

    var users []User
    err := json.NewDecoder(rec.Body).Decode(&users)
    assert.NoError(t, err)
    assert.Len(t, users, 2)
}

func TestUserHandler_Create(t *testing.T) {
    store := NewInMemoryStore()
    handler := NewUserHandler(store)

    body := map[string]string{
        "name":  "Charlie",
        "email": "charlie@example.com",
    }
    jsonBody, _ := json.Marshal(body)

    req := httptest.NewRequest(http.MethodPost, "/users",
        bytes.NewBuffer(jsonBody))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusCreated, rec.Code)

    var created User
    json.NewDecoder(rec.Body).Decode(&created)
    assert.Equal(t, "Charlie", created.Name)
    assert.NotZero(t, created.ID)
}

7.2 httptest.NewServer (Mocking External APIs)

package client_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestHTTPClient_GetUser(t *testing.T) {
    // Test server that mocks an external service
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Validate the request
        assert.Equal(t, "/api/users/1", r.URL.Path)
        assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]interface{}{
            "id":    1,
            "name":  "Alice",
            "email": "alice@example.com",
        })
    }))
    defer ts.Close()

    client := NewUserClient(ts.URL, "test-token")

    user, err := client.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}

func TestHTTPClient_ServerError(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
    }))
    defer ts.Close()

    client := NewUserClient(ts.URL, "test-token")
    _, err := client.GetUser(1)
    assert.Error(t, err)
}

func TestHTTPClient_MultipleEndpoints(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode([]User{{ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}})
    })
    mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    ts := httptest.NewServer(mux)
    defer ts.Close()

    client := NewUserClient(ts.URL, "token")

    users, err := client.ListUsers()
    assert.NoError(t, err)
    assert.Len(t, users, 2)
}

8. Stub Patterns

8.1 Interface-Based Stubs

// Time Stub: making time.Now() replaceable
type Clock interface {
    Now() time.Time
}

type RealClock struct{}
func (r RealClock) Now() time.Time { return time.Now() }

type FakeClock struct {
    fakeTime time.Time
}
func (f FakeClock) Now() time.Time { return f.fakeTime }

// File System Stub
type FileSystem interface {
    ReadFile(name string) ([]byte, error)
    WriteFile(name string, data []byte, perm os.FileMode) error
    Exists(name string) bool
}

type OSFileSystem struct{}
func (f OSFileSystem) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
func (f OSFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
    return os.WriteFile(name, data, perm)
}
func (f OSFileSystem) Exists(name string) bool {
    _, err := os.Stat(name)
    return !os.IsNotExist(err)
}

// In-memory file system stub
type MemoryFileSystem struct {
    files map[string][]byte
}

func NewMemoryFileSystem() *MemoryFileSystem {
    return &MemoryFileSystem{files: make(map[string][]byte)}
}

func (m *MemoryFileSystem) ReadFile(name string) ([]byte, error) {
    data, ok := m.files[name]
    if !ok {
        return nil, fmt.Errorf("file not found: %s", name)
    }
    return data, nil
}

func (m *MemoryFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
    m.files[name] = data
    return nil
}

func (m *MemoryFileSystem) Exists(name string) bool {
    _, ok := m.files[name]
    return ok
}

// Service test using stubs
type ConfigService struct {
    fs    FileSystem
    clock Clock
}

func (s *ConfigService) LoadConfig(path string) (*Config, error) {
    data, err := s.fs.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    cfg.LoadedAt = s.clock.Now()
    return &cfg, nil
}

8.2 Database Stub (sqlmock)

package repository_test

import (
    "context"
    "regexp"
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
    "github.com/stretchr/testify/assert"
)

func TestUserRepository_FindByID(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("failed to create sqlmock: %v", err)
    }
    defer db.Close()

    rows := sqlmock.NewRows([]string{"id", "name", "email"}).
        AddRow(1, "Alice", "alice@example.com")

    mock.ExpectQuery(regexp.QuoteMeta("SELECT id, name, email FROM users WHERE id = ?")).
        WithArgs(1).
        WillReturnRows(rows)

    repo := NewSQLUserRepository(db)
    user, err := repo.FindByID(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)

    assert.NoError(t, mock.ExpectationsWereMet())
}

func TestUserRepository_Save(t *testing.T) {
    db, mock, _ := sqlmock.New()
    defer db.Close()

    mock.ExpectExec(regexp.QuoteMeta(
        "INSERT INTO users (name, email) VALUES (?, ?)")).
        WithArgs("Bob", "bob@example.com").
        WillReturnResult(sqlmock.NewResult(42, 1))

    repo := NewSQLUserRepository(db)
    user := &User{Name: "Bob", Email: "bob@example.com"}
    err := repo.Save(context.Background(), user)

    assert.NoError(t, err)
    assert.Equal(t, 42, user.ID)
    assert.NoError(t, mock.ExpectationsWereMet())
}

9. Building a Mock Server

// mockserver/server.go
package mockserver

import (
    "encoding/json"
    "io"
    "net/http"
    "net/http/httptest"
    "sync"
    "testing"
)

type RequestRecord struct {
    Method  string
    Path    string
    Headers http.Header
    Body    []byte
}

type MockResponse struct {
    Status  int
    Headers map[string]string
    Body    interface{}
}

type Route struct {
    Method   string
    Path     string
    Response MockResponse
}

type MockServer struct {
    t        *testing.T
    server   *httptest.Server
    routes   []Route
    requests []RequestRecord
    mu       sync.Mutex
}

func New(t *testing.T) *MockServer {
    ms := &MockServer{t: t}

    mux := http.NewServeMux()
    mux.HandleFunc("/", ms.handleRequest)

    ms.server = httptest.NewServer(mux)
    t.Cleanup(ms.server.Close)

    return ms
}

func (ms *MockServer) handleRequest(w http.ResponseWriter, r *http.Request) {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    body, _ := io.ReadAll(r.Body)
    ms.requests = append(ms.requests, RequestRecord{
        Method:  r.Method,
        Path:    r.URL.Path,
        Headers: r.Header.Clone(),
        Body:    body,
    })

    for _, route := range ms.routes {
        if route.Method == r.Method && route.Path == r.URL.Path {
            for k, v := range route.Response.Headers {
                w.Header().Set(k, v)
            }
            if _, ok := route.Response.Headers["Content-Type"]; !ok {
                w.Header().Set("Content-Type", "application/json")
            }
            w.WriteHeader(route.Response.Status)
            if route.Response.Body != nil {
                json.NewEncoder(w).Encode(route.Response.Body)
            }
            return
        }
    }

    ms.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
    http.Error(w, "Not Found", http.StatusNotFound)
}

func (ms *MockServer) AddRoute(method, path string, resp MockResponse) *MockServer {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    ms.routes = append(ms.routes, Route{Method: method, Path: path, Response: resp})
    return ms
}

func (ms *MockServer) URL() string {
    return ms.server.URL
}

func (ms *MockServer) Requests() []RequestRecord {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    return append([]RequestRecord{}, ms.requests...)
}

func (ms *MockServer) AssertRequestCount(t *testing.T, method, path string, count int) {
    t.Helper()
    actual := 0
    for _, r := range ms.Requests() {
        if r.Method == method && r.Path == path {
            actual++
        }
    }
    if actual != count {
        t.Errorf("%s %s: request count = %d, want %d", method, path, actual, count)
    }
}

// Usage example
func TestWithMockServer(t *testing.T) {
    ms := New(t)

    ms.AddRoute("GET", "/api/users", MockResponse{
        Status: 200,
        Body:   []map[string]interface{}{{"id": 1, "name": "Alice"}},
    })
    ms.AddRoute("POST", "/api/users", MockResponse{
        Status: 201,
        Body:   map[string]interface{}{"id": 2, "name": "Bob"},
    })

    client := NewAPIClient(ms.URL())

    users, err := client.GetUsers()
    assert.NoError(t, err)
    assert.Len(t, users, 1)

    ms.AssertRequestCount(t, "GET", "/api/users", 1)
}

10. Benchmark Tests

package calculator_test

import (
    "fmt"
    "strings"
    "testing"
)

// Benchmark: run with go test -bench=.
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

func BenchmarkFibonacci(b *testing.B) {
    sizes := []int{10, 20, 30}
    for _, n := range sizes {
        n := n
        b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Fibonacci(n)
            }
        })
    }
}

// Track memory allocations
func BenchmarkStringConcat(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        var s string
        for j := 0; j < 100; j++ {
            s += "x"
        }
        _ = s
    }
}

func BenchmarkStringBuilder(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}

11. Quizzes

Quiz 1: assert vs require

What is the difference between assert.Equal and require.Equal? When should you use each?

Answer:

  • assert.Equal: test continues running even on failure. Calls t.Error() internally.
  • require.Equal: test stops immediately on failure. Calls t.FailNow() internally.

Explanation:

Use require when subsequent code depends on a precondition:

func TestUserCreation(t *testing.T) {
    user, err := CreateUser("Alice", "alice@example.com")
    require.NoError(t, err)  // if error, user is nil, next line would panic
    require.NotNil(t, user)

    assert.Equal(t, "Alice", user.Name)  // independent assertions use assert
    assert.NotEmpty(t, user.ID)
}

Use assert when you want to check multiple independent conditions and see all failures at once.

Quiz 2: Table-Driven Test Closure Trap

What is the bug in the following table-driven test?

tests := []struct {
    name  string
    input int
    want  int
}{
    {"double 2", 2, 4},
    {"double 5", 5, 10},
    {"double 10", 10, 20},
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := Double(tt.input)
        assert.Equal(t, tt.want, result)
    })
}

Answer: With t.Parallel(), the closure shares the loop variable tt.

Explanation: When t.Parallel() is called, the subtest runs concurrently. The closure captures tt by reference, so all goroutines share the same variable. By the time they run, the loop has finished and tt holds the last value.

Fix:

for _, tt := range tests {
    tt := tt  // capture loop variable in local variable
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := Double(tt.input)
        assert.Equal(t, tt.want, result)
    })
}

In Go 1.22+, loop variable semantics changed and this issue no longer applies.

Quiz 3: Mock vs Stub

Explain the difference between a Mock and a Stub, and when to use each.

Answer:

  • Stub: a simple replacement that returns pre-configured responses. Used for state verification.
  • Mock: an object that verifies specific methods were called with specific arguments. Used for behavior verification.

Explanation:

Stub example — fixed response:

type StubUserRepo struct{}
func (s StubUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
    return &User{ID: id, Name: "Test User"}, nil
}

Mock example — call verification:

mockRepo.EXPECT().FindByID(ctx, 1).Return(user, nil).Times(1)
// After the test, automatically verifies FindByID was called exactly once

Rule of thumb: use a Mock when your test needs to verify whether/how many times/with what arguments a method was called. Use a Stub when you simply need a certain response to be returned.

Quiz 4: The go test -race Flag

What does go test -race detect, and when should you use it?

Answer: It detects race conditions — situations where multiple goroutines access the same memory location concurrently without synchronization.

Explanation:

// This code is caught by -race
var counter int

func TestRaceCondition(t *testing.T) {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // DATA RACE: unsynchronized write
        }()
    }
    wg.Wait()
}

The -race flag has significant runtime overhead (5–10x slower), so it's best used in CI/CD pipelines or periodically when writing concurrent code rather than on every local test run.

Quiz 5: The 100% Coverage Trap

Does 100% test coverage guarantee that code is bug-free? What are the pitfalls of measuring coverage?

Answer: No. 100% coverage does not guarantee the absence of bugs.

Explanation: Coverage measures whether code was executed, not whether it behaves correctly.

func Divide(a, b int) int {
    return a / b
}

// 100% line coverage but misses a critical case
func TestDivide(t *testing.T) {
    assert.Equal(t, 5, Divide(10, 2))
    // No test for b=0, which causes a panic at runtime
}

More important than coverage numbers:

  1. Boundary value testing (0, -1, max values, etc.)
  2. Error path testing
  3. Concurrency testing
  4. Integration testing

Coverage is a tool to find untested code paths, not a measure of test quality. A test suite with 70% meaningful coverage is far more valuable than one with 100% trivial coverage.