本文 AI 產出,尚未審核

Golang – 測試與除錯

單元:整合測試與模擬(mock)


簡介

在大型 Go 專案中,單元測試 能保證每個函式在獨立環境下正確執行,而 整合測試 則驗證多個元件協同工作的行為。當測試對象依賴外部資源(資料庫、HTTP API、訊息佇列…)時,直接呼叫真實服務會讓測試變慢、難以重現,甚至產生副作用。這時 模擬(mock) 就派上用場:它以「假」的實作取代真實依賴,讓測試只關注核心邏輯。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步在 Go 中完成 整合測試 + mock,讓測試更快、更可靠,也更符合 CI/CD 流程的需求。


核心概念

1. 介面(interface)是 Mock 的基礎

Go 的介面抽象了行為,讓不同的實作可以互相替換。要對外部依賴做 mock,第一步就是 為依賴定義介面,然後在程式碼中只依賴介面而非具體型別。

// user_repo.go
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, u *User) error
}

實際的資料庫實作會滿足 UserRepository,測試時則提供一個「假」的實作。

2. 手寫 Mock vs. 產生工具

  • 手寫 Mock:簡單、可讀性高,適合依賴較少的介面。
  • 自動產生:如 gomock、moq、mockery,可以根據介面自動產生大量樣板程式碼,減少手動錯誤。

以下示範使用官方的 testing 包手寫一個簡易 mock,接著再展示 gomock 的使用方式。

3. 測試套件結構建議

/project
│
├─ internal/
│   ├─ user/
│   │   ├─ service.go        // 业务逻辑
│   │   ├─ repository.go     // 介面定義
│   │   └─ repository_impl.go// 真實 DB 實作
│   └─ ...
│
├─ mock/
│   └─ user_repository_mock.go // 手寫或產生的 mock
│
└─ service_test.go            // 整合測試

程式碼範例

範例 1️⃣ 手寫 Mock

// mock/user_repository_mock.go
type UserRepositoryMock struct {
    // 用 map 來模擬資料庫
    store map[int64]*User
    // 用於驗證呼叫次數
    FindCalls   int
    CreateCalls int
}

func NewUserRepositoryMock() *UserRepositoryMock {
    return &UserRepositoryMock{store: make(map[int64]*User)}
}

func (m *UserRepositoryMock) FindByID(ctx context.Context, id int64) (*User, error) {
    m.FindCalls++
    if u, ok := m.store[id]; ok {
        return u, nil
    }
    return nil, fmt.Errorf("user not found")
}

func (m *UserRepositoryMock) Create(ctx context.Context, u *User) error {
    m.CreateCalls++
    if _, exists := m.store[u.ID]; exists {
        return fmt.Errorf("duplicate user")
    }
    m.store[u.ID] = u
    return nil
}

測試中使用:

func TestService_CreateUser(t *testing.T) {
    mockRepo := NewUserRepositoryMock()
    svc := NewUserService(mockRepo) // 只依賴介面

    user := &User{ID: 1, Name: "Alice"}
    if err := svc.Register(context.Background(), user); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if mockRepo.CreateCalls != 1 {
        t.Errorf("expected Create to be called once, got %d", mockRepo.CreateCalls)
    }
}

範例 2️⃣ 使用 gomock 自動產生 Mock

  1. 安裝套件
go get github.com/golang/mock/gomock
go install github.com/golang/mock/mockgen@latest
  1. 產生 Mock
mockgen -source=internal/user/repository.go -destination=mock/user_repository_mock.go -package=mock
  1. 測試程式
func TestService_FindUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mock.NewMockUserRepository(ctrl)
    // 設定期望:FindByID 會被呼叫一次,且回傳指定結果
    mockRepo.
        EXPECT().
        FindByID(gomock.Any(), int64(42)).
        Return(&User{ID: 42, Name: "Bob"}, nil).
        Times(1)

    svc := NewUserService(mockRepo)

    user, err := svc.GetProfile(context.Background(), 42)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if user.Name != "Bob" {
        t.Errorf("expected name Bob, got %s", user.Name)
    }
}

範例 3️⃣ Mock HTTP Client

當服務需要呼叫外部 REST API 時,最常見的做法是 介面化 http.Client,或直接使用 httptest 產生測試伺服器。

type HTTPClient interface {
    Do(req *http.Request) (*http.Response, error)
}

// 真實實作
type RealHTTPClient struct {
    client *http.Client
}
func (c *RealHTTPClient) Do(req *http.Request) (*http.Response, error) {
    return c.client.Do(req)
}

測試時使用 httptest.Server:

func TestService_FetchRemote(t *testing.T) {
    // 建立假伺服器
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprint(w, `{"id":123,"status":"ok"}`)
    }))
    defer ts.Close()

    // 使用內建 http.Client 指向測試伺服器
    client := &RealHTTPClient{client: &http.Client{}}
    svc := NewRemoteService(client, ts.URL)

    resp, err := svc.GetStatus(context.Background())
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if resp.ID != 123 {
        t.Errorf("expected ID 123, got %d", resp.ID)
    }
}

範例 4️⃣ Mock 時間(time.Now)

時間相關的程式碼常常導致測試不穩定。將 time.Now 抽象成介面即可:

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

測試時:

type FixedClock struct{ fixed time.Time }
func (c FixedClock) Now() time.Time { return c.fixed }

func TestService_Expiry(t *testing.T) {
    fixed := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
    svc := NewTokenService(FixedClock{fixed})

    if !svc.IsExpired(time.Hour) {
        t.Errorf("expected token to be expired")
    }
}

範例 5️⃣ Mock 資料庫交易(Transaction)

如果程式使用 *sql.Tx,可以透過介面抽象交易行為,讓測試不必真的提交或回滾。

type Tx interface {
    ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
    Commit() error
    Rollback() error
}

在測試中使用 github.com/DATA-DOG/go-sqlmock:

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

    // 設定期望的 SQL 執行順序
    mock.ExpectBegin()
    mock.ExpectExec("UPDATE accounts SET balance").
        WithArgs(100, 1).
        WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectExec("UPDATE accounts SET balance").
        WithArgs(-100, 2).
        WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectCommit()

    repo := NewSQLUserRepository(db)
    svc := NewTransferService(repo)

    if err := svc.Transfer(context.Background(), 1, 2, 100); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatalf("unmet expectations: %v", err)
    }
}

常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記介面化依賴 直接在程式碼裡 sql.DB、http.Client,導致無法注入 mock。 先設計介面,再在 main 或 DI 容器中注入真實實作。
Mock 行為過於具體 把所有細節都寫進 mock,測試變成「實作驗證」而非「行為驗證」。 只模擬必要的輸入/輸出,使用 gomock.Any() 或 gomock.Match 來寬鬆匹配。
測試相依外部環境 測試需要真實資料庫或網路,CI 執行不穩定。 完全使用 mock 或在 CI 中使用 Docker 容器提供臨時環境。
未檢查 mock 呼叫次數 忽略 Times(),可能隱藏重複呼叫或遺漏呼叫的問題。 明確設定 Times(1)、Times(0),讓測試失敗時能快速定位。
Mock 產生工具版本不一致 不同開發者使用不同版本的 mockgen,產生的程式碼差異大。 在 go.mod 中鎖定工具版本,或使用 go install 指定版本。

最佳實踐

  1. 介面只抽象行為,不要把資料結構也搬進介面。
  2. 測試命名:Test<Service>_<Scenario>_<Expectation>,讓失敗訊息一目了然。
  3. 使用 table‑driven 測試,同一個測試函式跑多組資料,減少重複程式碼。
  4. 保持 mock 輕量:若需要大量行為,考慮拆分介面或使用 stub(回傳固定值)代替 full mock。
  5. 在 CI 中加入 race detector:go test -race ./...,確保 mock 沒有引入競爭條件。

實際應用場景

  1. 微服務間的 RPC 呼叫

    • 介面化 gRPC client,使用 gomock 模擬回傳結果,驗證服務在不同錯誤碼下的容錯邏輯。
  2. 資料庫遷移腳本

    • 使用 sqlmock 驗證 migration 程式在不同 schema 狀態下的 SQL 執行順序與錯誤處理。
  3. 第三方支付 API

    • 透過 httptest.Server 建立假支付平台,測試簽名、回傳格式與重試機制。
  4. 背景工作(worker)與佇列

    • 把 Kafka/Redis client 抽象為介面,mock 消費者的 Consume 方法,驗證工作者在訊息失敗時的重試與死信處理。
  5. 功能旗標(Feature Flag)服務

    • 使用 mock 的 flag provider,測試不同旗標組合下的業務分支,確保旗標開關不會破壞主流程。

總結

  • Mock 是整合測試的核心工具,讓我們在不依賴外部資源的情況下,驗證系統的行為與邊界。
  • 先 介面化依賴,再選擇 手寫或自動產生 的 mock;兩者皆有適用情境。
  • 注意 呼叫次數、參數匹配,以及 避免過度耦合,才能寫出可維護、易閱讀的測試。
  • 透過上述範例與最佳實踐,你可以在日常開發、CI/CD 流程中,快速建立可靠的整合測試,提升程式碼品質與部署信心。

祝你在 Go 的測試之路上,寫得更快、測得更穩! 🚀