本文 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:簡單、可讀性高,適合依賴較少的介面。
  • 自動產生:如 gomockmoqmockery,可以根據介面自動產生大量樣板程式碼,減少手動錯誤。

以下示範使用官方的 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.DBhttp.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 detectorgo 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 的測試之路上,寫得更快、測得更穩! 🚀