Golang 大補帖:我那個「偶爾」會拿出來用的私藏工具與套件清單!(Part 4)

Golang 大補帖:我那個「偶爾」會拿出來用的私藏工具與套件清單!(Part 4)

TL;DR

這篇文章將帶您深入 Go 語言的世界,聚焦於如何透過先進的工具和實踐來顯著提升軟體品質與開發效率。我們將首先探索 uber-go/fx 依賴注入框架,學習如何解耦程式碼,讓應用程式更具模組化和可測試性。接著,我們將介紹多款 Go 語言中熱門的測試框架,包括 stretchr/testifysmartystreets/goconveyonsi/ginkgo,它們能幫助您更有效率地撰寫清晰、可靠的測試。最後,我們還會深入了解 Mock 工具,如 jarcoal/httpmockDATA-DOG/go-sqlmockuber-go/mock,教您如何在測試中隔離外部依賴,讓單元測試更快速且穩定。準備好提升您的 Go 語言開發技能了嗎?讓我們開始吧!

依賴注入:使用 uber-go/fx 打造模組化應用

在現代 Go 應用程式開發中,隨著專案規模的擴大,如何有效管理各個組件之間的依賴關係,成為確保程式碼可維護性可測試性可擴展性的關鍵。這正是依賴注入 (Dependency Injection, DI) 設計模式所要解決的核心問題。

uber-go/fx 是由 Uber 開源的一個強大而輕量級的 Go 語言依賴注入框架。它旨在幫助開發者以更結構化、模組化的方式構建應用程式,自動處理組件的生命週期和依賴關係,讓您能專注於業務邏輯的實作。

什麼是 uber-go/fx

uber-go/fx 是一個基於反射 (Reflection) 的框架,它允許您聲明式地定義應用程式中的組件及其依賴關係。透過 fx.Providefx.Invoke 等核心函數,fx 會自動解析組件圖,並在運行時正確地實例化和連接這些組件。這大大減少了手動管理依賴的複雜性,使得程式碼更清晰、更易於測試。

uber-go/fx 的核心優勢:

  1. 自動化依賴解析: 您只需聲明組件需要的依賴,fx 會使用反射機制自動尋找並提供這些依賴,無需手動傳遞。
  2. 模組化設計: 鼓勵將應用程式劃分為獨立的模組,每個模組負責一組相關的功能和依賴,提升程式碼的組織性。
  3. 生命週期管理: fx 提供了強大的生命週期管理機制,能夠在應用程式啟動時執行初始化操作,並在應用程式關閉時優雅地釋放資源 (例如關閉數據庫連接、停止背景服務)。
  4. 提升可測試性: 由於組件的依賴關係被解耦,您可以輕鬆地在測試中替換真實的依賴為 Mock 物件,實現單元測試的隔離性,使測試更快速、更穩定。
  5. 錯誤處理與日誌: 內建良好的錯誤處理和日誌功能,便於診斷和調試應用程式。
範例:最簡單的 fx 應用程式

以下是一個 uber-go/fx 最簡單的「Hello World」應用程式範例。雖然它目前沒有實際的業務邏輯輸出,但這個範例展示了 fx 如何啟動並管理應用程式的運行,包括它如何監聽作業系統訊號 (例如 SIGTERM) 來進行優雅關閉

package main

import (
	"go.uber.org/fx"
)

func main() {
	fx.New().Run()
}

運行方式:

在您的終端機中,導航到包含此 main.go 檔案的目錄,然後執行:

go run .

您將會看到類似以下的輸出:

[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] RUNNING

說明:

  • fx.New() 創建了一個新的 fx 應用實例。
  • .Run() 方法啟動了這個應用程式。它會執行以下重要任務:
    • 自動提供核心依賴: 如您所見的 [Fx] PROVIDE 日誌,fx 會自動提供其內部的核心組件,例如 fx.Lifecycle (用於管理應用程式的生命週期鉤子) 和 fx.Shutdowner (用於觸發應用程式的關閉)。
    • 啟動應用程式: 應用程式會進入 [Fx] RUNNING 狀態,並開始監聽作業系統訊號。
    • 監聽系統訊號: 最重要的是,fx.Run() 會自動設置訊號處理器,監聽如 SIGINT (Ctrl+C) 或 SIGTERM (由 Docker, Kubernetes 等發送的終止訊號)。當這些訊號被接收時,fx 會觸發應用程式的優雅關閉流程,這對於在生產環境中安全地停止服務至關重要。

如何停止這個應用程式:

在運行中的終端機中,按下 Ctrl+C。您將看到:

[Fx] RUNNING
^C
[Fx] INTERRUPT

fx 成功捕獲了 SIGINT 訊號並開始執行關閉流程。儘管這個簡單的範例沒有定義任何關閉時需要執行的邏輯,但在更複雜的應用中,您可以使用 fx.Lifecycle 來註冊清理函數,例如關閉數據庫連接、停止 HTTP 服務器等,確保資源的正確釋放。

fx.Provide:提供依賴

uber-go/fx 中,fx.Provide 是其核心功能之一。它用於告訴 fx 容器如何「提供」應用程式所需的各種依賴(即組件或服務)。當您使用 fx.Provide 註冊一個函數時,fx 會分析該函數的簽名:

  • 參數 (Dependencies): 函數的參數列表聲明了該函數自身所需的依賴。fx 會自動從其已知的所有提供者中找到並注入這些依賴。
  • 返回值 (Provided Values): 函數的返回值定義了它將「提供」給其他組件的依賴。一個提供函數可以返回一個或多個值,最後一個可選返回值可以是 error,用於指示提供過程中是否發生錯誤。

這種機制使得組件的創建和組裝變得自動化,您只需要定義如何創建每個組件,而 fx 會負責組裝它們。

fx.Lifecycle:管理組件生命週期

雖然 fx.Provide 負責組件的建立與依賴注入,但許多組件在應用程式啟動時需要執行初始化操作(例如啟動一個 HTTP 服務器、連接數據庫),而在應用程式關閉時需要執行清理操作(例如關閉服務器、斷開數據庫連接)。fx.Lifecycle 正是為了解決這些生命週期管理的需求而設計的。

fx.Lifecycle 是一個介面,它提供了 Append 方法,允許您註冊在應用程式啟動 (OnStart)停止 (OnStop) 時執行的回調函數。這些回調函數接收一個 context.Context 作為參數,使得您可以安全地在應用程式關閉時處理超時或取消操作。

範例:結合 fx.Providefx.Lifecycle 建立 HTTP 服務器

讓我們透過一個實際的例子來展示如何使用 fx.Provide 提供一個 HTTP 服務器,並利用 fx.Lifecycle 來管理它的啟動和關閉。

package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"net/http"
	"time"

	"go.uber.org/fx"
)

// NewHTTPServer 創建一個 HTTP 服務器並管理其生命週期
func NewHTTPServer(lc fx.Lifecycle) *http.Server {
	// 建立一個 HTTP Server 實例,監聽在 8080 端口
	srv := &http.Server{Addr: ":8080"}

	// 註冊生命週期鉤子
	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			// 在應用程式啟動時執行
			ln, err := net.Listen("tcp", srv.Addr)
			if err != nil {
				return err
			}
			fmt.Printf("Starting HTTP server at %s\n", srv.Addr)
			// 在一個 Goroutine 中啟動服務器,避免阻塞 OnStart 函數
			go func() {
				if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
					log.Printf("HTTP server failed: %v", err)
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			// 在應用程式停止時執行,優雅地關閉服務器
			log.Println("Stopping HTTP server...")
			// 可以在此設置關閉超時,例如 5 秒
			shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
			defer cancel()
			return srv.Shutdown(shutdownCtx)
		},
	})

	// 返回 HTTP 服務器實例,它將被 fx 容器管理
	return srv
}

func main() {
	fx.New(
		// 使用 fx.Provide 註冊 NewHTTPServer 函數,
		// 告訴 fx 如何提供一個 *http.Server 實例。
		// fx 會自動發現 NewHTTPServer 依賴 fx.Lifecycle 並注入它。
		fx.Provide(NewHTTPServer),

		// 為了讓服務器實際運行,我們需要一個 fx.Invoke 函數來「使用」這個服務器。
		// 在這個簡單的例子中,我們不需要對服務器本身做任何操作,
		// 因為 OnStart 鉤子已經負責啟動它。
		// 但如果沒有任何 Invoke,fx 會認為沒有任何事情需要執行,
		// 導致應用程式直接啟動並立刻停止。
		// 我們可以添加一個空的 Invoke,或者一個更實際的 Invoke 來配置路由。
		// 這裡我們添加一個簡單的路由配置作為 Invoke 的例子。
		fx.Invoke(func(s *http.Server) {
			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				fmt.Fprintf(w, "Hello from Fx HTTP Server!")
			})
			log.Println("HTTP server handler configured.")
		}),
	).Run()
}

運行與測試:

運行應用: 在終端機中執行 go run . 您將會看到類似以下的輸出,包括 Starting HTTP server at :8080

[Fx] PROVIDE    fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE    fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE    fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] PROVIDE    *http.Server <= main.NewHTTPServer()
[Fx] INVOKE             main.main.func1()
[Fx] BEFORE RUN provide: go.uber.org/fx.New.func1()
[Fx] RUN        provide: go.uber.org/fx.New.func1() in 209µs
[Fx] BEFORE RUN provide: main.NewHTTPServer()
[Fx] RUN        provide: main.NewHTTPServer() in 38.958µs
2025/06/02 11:40:18 HTTP server handler configured.
[Fx] HOOK OnStart               main.NewHTTPServer.func1() executing (caller: main.NewHTTPServer)
Starting HTTP server at :8080
[Fx] HOOK OnStart               main.NewHTTPServer.func1() called by main.NewHTTPServer ran successfully in 228.75µs
[Fx] RUNNING

發送 HTTP 請求: 開啟另一個終端機視窗,使用 curl 測試 HTTP 服務器:

curl http://localhost:8080

您應該會收到回應:Hello from Fx HTTP Server!

停止應用: 回到運行 go run . 的終端機視窗,按下 Ctrl+C。 您將會看到服務器優雅關閉的日誌:

^C[Fx] INTERRUPT
[Fx] HOOK OnStop                main.NewHTTPServer.func2() executing (caller: main.NewHTTPServer)
2025/06/02 11:39:11 Stopping HTTP server...
[Fx] HOOK OnStop                main.NewHTTPServer.func2() called by main.NewHTTPServer ran successfully in 399.917µs

這個範例清楚地展示了 uber-go/fx 如何透過 fx.Provide 自動處理組件的創建與依賴注入,並透過 fx.Lifecycle 實現對組件生命週期的精確控制,這對於構建健壯、可維護的 Go 應用程式至關重要。

fx.Invoke:啟動應用邏輯

uber-go/fx 中,fx.Invoke 函數用於指示 fx 應用程式在啟動時需要執行哪些核心業務邏輯。與 fx.Provide 負責「提供」組件不同,fx.Invoke 負責「使用」或「調用」這些已提供的組件。fx.Invoke 函數的參數列表會聲明其所需的依賴,fx 會自動將這些依賴注入到 Invoke 函數中,然後執行該函數。

一個 fx 應用程式必須至少有一個 fx.Invoke 才能啟動,否則 fx 會認為沒有任何需要執行的操作,應用程式會立即停止。

範例:透過 fx.Provide 管理不同依賴,並串接組件

現在,讓我們擴展之前的 HTTP 服務器範例,加入一個 HTTP 處理器 (http.Handler) 和一個多路復用器 (http.ServeMux)。這將展示 fx 如何管理多個不同類型的依賴,並將它們自動組裝起來。

我們將創建三個獨立的組件:

  1. EchoHandler 一個簡單的 HTTP 處理器,負責將請求體回顯到回應中。
  2. NewServeMux 一個 HTTP 多路復用器,它會將 /echo 路徑的請求路由到 EchoHandler
  3. NewHTTPServer 我們的 HTTP 服務器,現在它將接收一個 http.ServeMux 作為依賴,並使用它來處理請求。
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"time"

	"go.uber.org/fx"
)

// EchoHandler 是一個簡單的 HTTP Handler,它將請求體複製回響應
type EchoHandler struct{}

// NewEchoHandler 是一個提供 EchoHandler 的函數
func NewEchoHandler() *EchoHandler {
	return &EchoHandler{}
}

// ServeHTTP 處理 HTTP 請求,將請求體複製到響應中
func (e *EchoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if _, err := io.Copy(w, r.Body); err != nil {
		fmt.Fprintf(os.Stderr, "Failed to handle request: %v\n", err)
	}
}

// NewServeMux 建立一個 http.ServeMux,並將 /echo 路徑路由到 EchoHandler
func NewServeMux(echo *EchoHandler) *http.ServeMux {
	mux := http.NewServeMux()
	mux.Handle("/echo", echo) // 將 /echo 路徑交給 EchoHandler 處理
	log.Println("ServeMux configured with /echo handler.")
	return mux
}

// NewHTTPServer 創建一個 HTTP 服務器並管理其生命週期。
// 現在它依賴於 http.ServeMux。
func NewHTTPServer(lc fx.Lifecycle, mux *http.ServeMux) *http.Server {
	// 建立一個 HTTP Server 實例,將多路復用器作為其 Handler
	srv := &http.Server{Addr: ":8080", Handler: mux}

	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			ln, err := net.Listen("tcp", srv.Addr)
			if err != nil {
				return err
			}
			fmt.Printf("Starting HTTP server at %s\n", srv.Addr)
			go func() {
				if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
					log.Printf("HTTP server failed: %v", err)
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			log.Println("Stopping HTTP server...")
			shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
			defer cancel()
			return srv.Shutdown(shutdownCtx)
		},
	})
	return srv
}

func main() {
	fx.New(
		// 提供所有必要的組件
		fx.Provide(
			NewHTTPServer,  // 提供 HTTP 服務器
			NewEchoHandler, // 提供 Echo 處理器
			NewServeMux,    // 提供 HTTP 多路復用器,它將自動接收 EchoHandler
		),
		// 啟動應用程式,此處只需要 Invoke 服務器實例,
		// 因為生命週期鉤子和路由已經配置在各自的 Provide 函數中。
		// 如果沒有 Invoke,fx 會認為沒有任何需要執行的事情。
		fx.Invoke(func(*http.Server) {}), // 簡單的 Invoke 確保應用程式持續運行
	).Run()
}

運行與測試:

  1. 保存代碼: 將上述程式碼保存為 main.go
  2. 運行應用: 在終端機中執行 go run . 您會看到類似的輸出,表示所有組件都被正確提供並啟動:
  3. 停止應用: 回到運行 go run . 的終端機視窗,按下 Ctrl+C。 您將會看到服務器優雅關閉的日誌。

發送 HTTP 請求: 開啟另一個終端機視窗,使用 curl 測試 echo 端點:

curl -X POST -d 'hello' http://localhost:8080/echo

您應該會收到回應:hello (這是您發送的請求體被回顯回來)。

uber-go/fx 依賴注入框架總結

在本段文章中,我們全面探討了 uber-go/fx 這個強大的 Go 語言依賴注入框架。我們從最簡單的 fx 應用程式開始,了解了它如何自動處理應用程式的啟動和優雅關閉。

接著,我們深入學習了 fx.Provide 的核心機制,它是如何讓您聲明式地定義和提供應用程式所需的各種組件(依賴),以及 fx 如何自動解析這些依賴關係並將其注入到需要它們的組件中。

最重要的是,我們透過建立一個 HTTP 服務器,並將其拆分為 EchoHandlerhttp.ServeMuxhttp.Server 三個獨立組件的範例,清晰地展示了 fx 如何巧妙地管理多個不同類型的依賴,並自動將它們串接起來。同時,我們也看到了 fx.Lifecycle 在組件生命週期管理中的關鍵作用,確保了服務器能夠在應用程式啟動時正確啟動,並在關閉時優雅地停止,釋放資源。

透過 uber-go/fx,您可以告別手動管理複雜依賴關係的繁瑣,讓程式碼更加模組化可測試可維護。它不僅簡化了大型 Go 應用程式的開發和架構,也鼓勵了更好的設計實踐,例如單一職責原則和依賴反轉原則。無論您是開發微服務、Web 應用還是其他複雜的 Go 程式,uber-go/fx 都是一個值得您深入學習和採用的利器。


Go 語言測試:框架與工具選擇

在軟體開發生命週期中,測試是確保程式碼品質、穩定性與可靠性的關鍵環節。Go 語言本身透過內建的 testing 包提供了堅實的測試基礎,但社群中也湧現了許多優秀的第三方測試框架與工具,它們在不同層面擴展了 testing 的能力,提供了更豐富的斷言、更友善的語法或更視覺化的報告,以適應多樣化的測試需求。

本節將深入探討 Go 語言中最受歡迎的三個測試框架:stretchr/testifysmartystreets/goconveyonsi/ginkgo,並對它們各自的特點、優勢和適用場景進行比較,幫助您根據專案需求做出明智的選擇。

stretchr/testify:簡潔的斷言

testify 透過 assertrequire 兩個子包提供了豐富的斷言函數,讓您的測試程式碼更具可讀性。這裡我們將展示如何使用 assert 包進行各種常見的斷言。

假設我們有一個簡單的數學運算函數:

package main

// Add 執行兩個整數相加
func Add(a, b int) int {
	return a + b
}

// Divide 執行兩個整數相除,並處理除以零的情況
func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("cannot divide by zero")
	}
	return a / b, nil
}

現在,我們來為這些函數撰寫測試:

package main

import (
	"testing"

	"github.com/stretchr/testify/assert"
	// 如果需要中斷測試,可以使用 "github.com/stretchr/testify/require"
)

func TestMathOperations(t *testing.T) {
	// 測試 Add 函數
	t.Run("Add", func(t *testing.T) {
		// assert equality
		assert.Equal(t, 5, Add(2, 3), "兩數相加結果應為 5")

		// assert inequality
		assert.NotEqual(t, 6, Add(2, 3), "兩數相加結果不應為 6")
	})

	// 測試 Divide 函數
	t.Run("Divide", func(t *testing.T) {
		// 測試正常除法
		t.Run("success", func(t *testing.T) {
			result, err := Divide(10, 2)
			// assert no error (good for errors)
			assert.Nil(t, err, "除法操作不應該有錯誤")
			// assert equality for result
			assert.Equal(t, 5, result, "10 除以 2 結果應為 5")
		})

		// 測試除以零的情況
		t.Run("divide by zero error", func(t *testing.T) {
			result, err := Divide(10, 0)
			// assert error (good when you expect something)
			assert.NotNil(t, err, "除以零應該返回錯誤")
			assert.Contains(t, err.Error(), "cannot divide by zero", "錯誤訊息應包含 'cannot divide by zero'")
			assert.Equal(t, 0, result, "除以零結果應為 0")
		})
	})

	// 演示更多 assert 用法(圖片中未直接展示,但常用於證明物件非空後安全訪問)
	t.Run("Object Check", func(t *testing.T) {
		var obj *struct{ Value string }
		assert.Nil(t, obj, "obj 初始應該為 nil")

		obj = &struct{ Value string }{Value: "Something"}
		// now we know that object isn't nil, we are safe to make further assertions without causing any errors
		assert.NotNil(t, obj, "obj 不應為 nil")
		assert.Equal(t, "Something", obj.Value, "obj.Value 應為 'Something'")
	})
}

運行方式: 在終端機中執行 go test -v

smartystreets/goconvey:語義化的 BDD 結構與互動式報告

goconvey 透過 Convey 函數建立嵌套的測試結構,讓測試描述更具層次感和語義化,同時其最大的特色是提供互動式網頁報告。

假設我們有一個簡單的計數器結構:

package main

// Counter 是一個簡單的計數器
type Counter struct {
	Value int
}

// Increment 將計數器值增加 1
func (c *Counter) Increment() {
	c.Value++
}

// Reset 將計數器重置為 0
func (c *Counter) Reset() {
	c.Value = 0
}

現在,我們來為這個計數器撰寫 goconvey 測試:

package main

import (
	"testing"

	. "github.com/smartystreets/goconvey/convey"
)

func TestCounter(t *testing.T) {
	// 最頂層的 Convey 調用,通常用於描述被測試的組件
	Convey("Given a new Counter", t, func() {
		counter := Counter{} // 初始化一個新的計數器

		Convey("When the counter is incremented", func() {
			counter.Increment() // 執行操作

			Convey("Then the value should be 1", func() {
				So(counter.Value, ShouldEqual, 1) // 斷言結果
			})

			Convey("And if incremented again", func() {
				counter.Increment() // 執行第二次操作
				Convey("Then the value should be 2", func() {
					So(counter.Value, ShouldEqual, 2) // 斷言結果
				})
			})
		})

		Convey("When the counter is reset", func() {
			counter.Value = 5 // 先設定一個值
			counter.Reset()   // 執行重置操作

			Convey("Then the value should be 0", func() {
				So(counter.Value, ShouldEqual, 0) // 斷言結果
			})
		})
	})
}

運行方式:

  1. 運行方式: 在終端機中執行 go test -v
  2. 執行 goconvey 命令 (確保您已安裝:go install github.com/smartystreets/goconvey@latest)。
    它會自動在瀏覽器中打開一個頁面(通常是 http://localhost:8080),並實時顯示測試結果。當您修改並保存測試文件時,網頁會自動刷新。

onsi/ginkgo (與 onsi/gomega):結構化的 BDD 規範

ginkgo 是一個完整的 BDD 框架,通常搭配 gomega 斷言庫使用,提供高度結構化和富有表達力的測試語法。

假設我們正在開發一個簡單的書本管理功能,包含借閱和歸還操作:

package main

import "errors"

// Book 表示一本書
type Book struct {
	Title      string
	Author     string
	IsBorrowed bool
}

// Library 表示一個圖書館
type Library struct {
	Books map[string]*Book // 標題 -> 書本
}

// NewLibrary 創建一個新的圖書館實例
func NewLibrary() *Library {
	return &Library{
		Books: make(map[string]*Book),
	}
}

// AddBook 向圖書館添加一本書
func (l *Library) AddBook(book *Book) {
	l.Books[book.Title] = book
}

// BorrowBook 借閱一本書
func (l *Library) BorrowBook(title string) error {
	book, ok := l.Books[title]
	if !ok {
		return errors.New("book not found")
	}
	if book.IsBorrowed {
		return errors.New("book is already borrowed")
	}
	book.IsBorrowed = true
	return nil
}

// ReturnBook 歸還一本書
func (l *Library) ReturnBook(title string) error {
	book, ok := l.Books[title]
	if !ok {
		return errors.New("book not found")
	}
	if !book.IsBorrowed {
		return errors.New("book was not borrowed")
	}
	book.IsBorrowed = false
	return nil
}

現在,我們來為這個 Library 撰寫 ginkgo 測試:

package main

import (
	"context"
	"testing"
	"time"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

// 使用 var _ = Describe(...) 開始定義測試套件
var _ = Describe("Checking books out of the library", Label("library"), func() { // Label 可用於過濾測試

	var library *Library // 聲明一個 library 變數,以便在各個測試用例中訪問

	// BeforeEach 會在每個 'It' 函數執行前執行
	BeforeEach(func() {
		library = NewLibrary() // 為每個測試用例初始化一個新的圖書館
		library.AddBook(&Book{Title: "Les Miserables", Author: "Victor Hugo"})
	})

	// Context 用於描述一個特定的情境或狀態
	Context("When the book is in question", func() {
		It("should lend it to the reader", func() {
			// Expect 使用 Gomega 的斷言
			// To(Succeed()) 斷言函數返回 nil 錯誤
			Expect(library.BorrowBook("Les Miserables")).To(Succeed())

			// 檢查書本狀態是否已更新
			book := library.Books["Les Miserables"]
			Expect(book.IsBorrowed).To(BeTrue())
		})
	})

	Context("but the book has already been checked out", func() {
		BeforeEach(func() {
			// 在這個 Context 內的 It 函數執行前,先將書本標記為已借出
			library.Books["Les Miserables"].IsBorrowed = true
		})

		It("tells the reader the book is currently checked out", func() {
			err := library.BorrowBook("Les Miserables")
			// To(HaveOccurred()) 斷言有錯誤發生
			Expect(err).To(HaveOccurred())
			// To(ContainSubstring()) 斷言錯誤訊息包含特定子串
			Expect(err.Error()).To(ContainSubstring("book is already borrowed"))
		})
	})

	Context("and the book is eventually returned", func() {
		BeforeEach(func() {
			// 先將書本標記為已借出
			library.Books["Les Miserables"].IsBorrowed = true
		})

		It("lets the user place a hold and get notified later", func() {
			// 這裡可以模擬更複雜的邏輯,例如異步通知
			// Expect(someNotificationQueue).To(Receive("notification about Les Miserables ready for pickup"))
			// 這裡僅演示歸還邏輯
			Expect(library.ReturnBook("Les Miserables")).To(Succeed())
			Expect(library.Books["Les Miserables"].IsBorrowed).To(BeFalse())
		})
	})

	Context("when the book does not have the book in question", func() {
		It("tells the reader the book is unavailable", func() {
			err := library.BorrowBook("NonExistentBook")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("book not found"))
		})
	})

	// 演示 AfterEach 的使用
	AfterEach(func() {
		// 可以在這裡進行一些清理工作,例如重置全局狀態
		// fmt.Println("AfterEach: Test case finished.")
	})

	// 演示一個帶有 context 和 timeout 的斷言 (類似圖片中的 SpecTimeout)
	// Ginkgo v2 建議使用 context.WithTimeout 來管理測試中的超時
	It("should handle a long-running operation within timeout", func() {
		ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
		defer cancel()

		// 假設這是某個耗時操作的模擬
		// Expect(someLongRunningFunction(ctx)).To(Succeed(), SpecTimeout(100*time.Millisecond)) // 這是 ginkgo v1 的寫法
		// 在 Ginkgo v2 中,直接在 context 中設置超時更常見
		select {
		case <-time.After(100 * time.Millisecond): // 模擬一個耗時 100ms 的操作
			// Operation completed within timeout
			Expect(true).To(BeTrue()) // 確保斷言成功
		case <-ctx.Done():
			Fail("Operation timed out") // 如果 context 超時,則測試失敗
		}
	})
})

func TestLibrary(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Test Library Suite")
}

運行方式:

  1. 將上述 BookLibrary 的程式碼保存為 main.go (或任何您喜歡的名稱)。
  2. 將測試程式碼保存為 main_test.go
  3. 在終端機中,導航到包含這些文件的目錄。

在您的專案根目錄下,確保安裝了 ginkgogomega

go install github.com/onsi/ginkgo/v2/ginkgo@latest
go install github.com/onsi/gomega/...@latest # 通常 gomega 會隨 ginkgo 一起安裝,但如果遇到問題可以單獨安裝

執行 ginkgo 命令來運行測試:

ginkgo -v # 顯示詳細的測試輸出

或者使用 go test 命令運行,但會缺少 ginkgo 的詳細報告:

go test -v -ginkgo.v

三者比較總結

特性stretchr/testifysmartystreets/goconveyonsi/ginkgo (+ gomega)
類型testing 包的擴展與斷言/Mock 庫BDD 測試框架,帶有互動式報告BDD 測試框架
語法風格傳統 testing + 豐富的 assert/requireBDD 風格 (Convey, So, Should...)BDD 風格 (Describe, Context, It, Expect)
學習曲線平緩中等 (習慣 BDD 和其報告方式)中等 (需要掌握 BDD 概念和其豐富原語)
主要優勢豐富斷言、內建 Mock、輕量級、廣泛採用互動式網頁報告、自動刷新、高可讀性高度結構化、強力表達、並行測試、CI 友善
報告方式標準 Go Test 輸出瀏覽器中互動式網頁報告,標準輸出標準輸出,可生成多種格式報告 (JUnit XML)
適用場景幾乎所有 Go 專案、需要快速簡潔斷言和 MockingBDD 團隊、偏好視覺化和即時反饋、測試即文檔大型專案、嚴格遵循 BDD、追求測試套件的結構化與效率
侵入性中等 (要求特定測試結構)高 (要求嚴格的 BDD 測試結構)
Mocking內建 mock無內建,需搭配其他 Mock 庫 (如 go-mock)無內建,需搭配其他 Mock 庫 (如 go-mock)

如何選擇?

  • 初學者或追求簡潔高效: 如果您剛開始接觸 Go 測試,或者只想要對標準 testing 包進行增強,使其斷言更具表達力,那麼 stretchr/testify 是最佳選擇。它輕量、易學,且功能強大,能夠滿足絕大多數單元測試的需求。
  • BDD 倡導者或追求視覺化體驗: 如果您的團隊遵循 BDD 開發流程,並且希望測試程式碼能夠作為業務規範的活文檔,同時享受實時的、視覺化的測試反饋,那麼 smartystreets/goconvey 將會是您的首選。
  • 大型專案或需要高度結構化測試: 對於大型、複雜的專案,如果您的團隊需要更嚴格的測試結構、更強大的並行測試能力,並且希望測試程式碼能夠清晰地描述系統的行為,那麼 onsi/ginkgo 結合 onsi/gomega 將是您的理想選擇。它在可維護性和可讀性方面表現出色,尤其適合於有嚴格測試規範的企業級應用。

總之,這三個框架各有側重。選擇最適合您的工具,應基於您的專案規模、團隊偏好、開發流程以及對測試結果報告的需求。在下一部分,我們將深入探討 Mocking 工具,它們將進一步提升您的測試效率和隔離性。


Go 語言測試:Mock 工具實戰

單元測試的目標是隔離測試單元,確保其邏輯正確性,而不受外部依賴的影響。然而,在實際應用中,我們的程式碼往往會依賴於外部服務,例如 HTTP API、數據庫、檔案系統等。直接在單元測試中連接這些真實的外部服務,會導致測試變慢、不穩定,甚至引入不可預測的行為。

這時候,Mock 技術就派上用場了。Mock 透過創建「模擬物件」(Mock Objects)來替代真實的外部依賴,讓您可以精確控制這些依賴的行為和返回值,從而實現測試的隔離性、穩定性和效率。

本節將介紹三個 Go 語言中常用且功能強大的 Mock 工具:jarcoal/httpmockDATA-DOG/go-sqlmockuber-go/mock

jarcoal/httpmock:模擬 HTTP 請求與回應

當您的 Go 應用程式需要發送 HTTP 請求(例如呼叫 RESTful API)時,jarcoal/httpmock 是一個非常便捷的工具,它允許您攔截標準庫的 http.Client 發出的請求,並返回預設的 Mock 回應,而無需實際發送網路請求。這對於測試依賴外部 API 的服務非常有用。

主要用法:

  1. 啟用/禁用 Mocking: 使用 httpmock.Activate() 啟用攔截,httpmock.DeactivateAndReset() 在測試結束後禁用並清除所有註冊的 Mock 回應。
  2. 註冊回應: 使用 httpmock.RegisterResponder 註冊一個對特定 HTTP 方法和 URL 的 Mock 回應。您可以指定回應的狀態碼、頭部和內容。
  3. 靈活匹配: 支援精確 URL 匹配、正則表達式匹配,甚至自定義匹配函數。
  4. 計數器: 可以追蹤每個 Mock 回應被調用的次數。
範例:獲取文章列表

假設我們有一個服務,負責從一個外部 API 獲取文章列表:

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

// Article 結構體
type Article struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

// ArticleService 負責獲取文章
type ArticleService struct {
	Client  *http.Client
	BaseURL string
}

// NewArticleService 建立新的 ArticleService
func NewArticleService(client *http.Client, baseURL string) *ArticleService {
	return &ArticleService{Client: client, BaseURL: baseURL}
}

// GetArticles 獲取所有文章
func (s *ArticleService) GetArticles() ([]Article, error) {
	resp, err := s.Client.Get(s.BaseURL + "/articles")
	if err != nil {
		return nil, fmt.Errorf("failed to make request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("bad status code: %d", resp.StatusCode)
	}

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response body: %w", err)
	}

	var articles []Article
	if err := json.Unmarshal(bodyBytes, &articles); err != nil {
		return nil, fmt.Errorf("failed to unmarshal articles: %w", err)
	}
	return articles, nil
}

// GetArticleByID 獲取單篇文章
func (s *ArticleService) GetArticleByID(id string) (*Article, error) {
	resp, err := s.Client.Get(s.BaseURL + "/articles/" + id)
	if err != nil {
		return nil, fmt.Errorf("failed to make request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("bad status code: %d", resp.StatusCode)
	}

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response body: %w", err)
	}

	var article Article
	if err := json.Unmarshal(bodyBytes, &article); err != nil {
		return nil, fmt.Errorf("failed to unmarshal article: %w", err)
	}
	return &article, nil
}

測試 ArticleService

package main

import (
	"encoding/json"
	"io"
	"net/http"
	"regexp"
	"testing"

	"github.com/jarcoal/httpmock"
	"github.com/stretchr/testify/assert"
)

func TestArticleService(t *testing.T) {
	// 啟用 httpmock
	httpmock.Activate()
	// 在測試結束後,停用 httpmock 並重置所有註冊器
	defer httpmock.DeactivateAndReset()

	service := NewArticleService(http.DefaultClient, "https://api.mybiz.com")

	t.Run("GetArticles_Success_ExactMatch", func(t *testing.T) {
		// 註冊一個 Mock 回應,精確匹配 GET /articles 請求
		httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles",
			httpmock.NewStringResponder(200, `[{"id":"1", "name":"Great Article"}, {"id":"2", "name":"Another Article"}]`))

		articles, err := service.GetArticles()
		assert.NoError(t, err)
		assert.NotNil(t, articles)
		assert.Len(t, articles, 2)
		assert.Equal(t, "Great Article", articles[0].Name)

		// 驗證 httpmock 請求次數
		assert.Equal(t, 1, httpmock.GetCallCountInfo()["GET https://api.mybiz.com/articles"])
	})

	t.Run("GetArticleByID_Success_RegexMatch", func(t *testing.T) {
		// 註冊一個 Mock 回應,使用正則表達式匹配 GET /articles/{id} 請求
		httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://api\.mybiz\.com/articles/(\d+)$`),
			httpmock.NewBytesResponder(200, []byte(`{"id":"3", "name":"My New Article"}`)))

		article, err := service.GetArticleByID("3")
		assert.NoError(t, err)
		assert.NotNil(t, article)
		assert.Equal(t, "3", article.ID)
		assert.Equal(t, "My New Article", article.Name)

		// 驗證 httpmock 請求次數
		assert.Equal(t, 1, httpmock.GetCallCountInfo()["GET https://api.mybiz.com/articles/3"])
	})

	t.Run("GetArticles_ServerError", func(t *testing.T) {
		// 註冊一個 Mock 回應,模擬伺服器錯誤
		httpmock.RegisterResponder("GET", "https://api.mybiz.com/articles",
			httpmock.NewStringResponder(500, "Internal Server Error"))

		articles, err := service.GetArticles()
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "bad status code: 500")
		assert.Nil(t, articles)
	})

	t.Run("PostArticle_Success", func(t *testing.T) {
		// 註冊 POST 請求的 Mock 回應
		httpmock.RegisterResponder("POST", "https://api.mybiz.com/articles",
			func(req *http.Request) (*http.Response, error) {
				// 可以在這裡檢查請求體
				bodyBytes, _ := io.ReadAll(req.Body)
				var newArticle Article
				_ = json.Unmarshal(bodyBytes, &newArticle)
				if newArticle.Name == "" {
					return httpmock.NewStringResponse(400, "Name cannot be empty"), nil
				}
				return httpmock.NewStringResponse(201, `{"id":"new-123", "name":"`+newArticle.Name+`"}`), nil
			})

		// 假設這是您的 POST 邏輯(在此處簡化)
		reqBody := `{"name":"A New Article"}`
		resp, err := http.Post(service.BaseURL+"/articles", "application/json", io.NopCloser(http.MaxBytesReader(nil, io.NopCloser(NewTestReader(reqBody)), int64(len(reqBody)))))
		assert.NoError(t, err)
		assert.Equal(t, 201, resp.StatusCode)

		var createdArticle Article
		bodyBytes, _ := io.ReadAll(resp.Body)
		json.Unmarshal(bodyBytes, &createdArticle)
		assert.Equal(t, "new-123", createdArticle.ID)
		assert.Equal(t, "A New Article", createdArticle.Name)

		// 測試錯誤 POST
		reqBody = `{"name":""}`
		resp, err = http.Post(service.BaseURL+"/articles", "application/json", io.NopCloser(http.MaxBytesReader(nil, io.NopCloser(NewTestReader(reqBody)), int64(len(reqBody)))))
		assert.NoError(t, err)
		assert.Equal(t, 400, resp.StatusCode)
		respBody, _ := io.ReadAll(resp.Body)
		assert.Equal(t, "Name cannot be empty", string(respBody))
	})

}

// 協助 POST 測試的輔助函數 (因為 io.NopCloser 和 http.MaxBytesReader 的限制)
// 實際應用中,通常會透過服務的特定方法來發送 POST 請求
// 這裡僅為演示 httpmock 接收請求體而簡化
type testReader struct {
	s string
	i int64 // current reading index
}

func NewTestReader(s string) *testReader {
	return &testReader{s, 0}
}
func (r *testReader) Read(p []byte) (n int, err error) {
	if r.i >= int64(len(r.s)) {
		return 0, io.EOF
	}
	n = copy(p, r.s[r.i:])
	r.i += int64(n)
	return
}

運行方式:

  1. 將上述 ArticleArticleService 和測試代碼放在同一個 Go 模組中。
  2. 執行 go test -v

DATA-DOG/go-sqlmock:模擬 SQL 數據庫操作

在測試需要與 SQL 數據庫互動的 Go 程式碼時,啟動一個真實的數據庫實例會非常耗時且增加測試的複雜性。DATA-DOG/go-sqlmock 提供了一個強大的解決方案,它模擬了 Go 標準庫 database/sql 接口,讓您可以在不連接實際數據庫的情況下,模擬任何 SQL 查詢的行為和結果。

主要用法:

  1. 創建 Mock 數據庫: 使用 sqlmock.New() 創建一個模擬的 *sql.DB 實例和一個 sqlmock.Sqlmock 控制器。
  2. 設定預期: 透過 mock.ExpectQuerymock.ExpectExec 等方法設定對特定 SQL 語句的預期,並定義這些語句應該返回的結果 (WillReturnRowsWillReturnResult 等)。
  3. 驗證預期: 在測試結束時,使用 mock.ExpectationsWereMet() 檢查所有預期的 SQL 操作是否都已發生。
範例:用戶數據庫操作

假設我們有一個簡單的用戶數據庫操作服務:

package main

import (
	"database/sql"
	"fmt"
)

// User 結構體
type User struct {
	ID    int
	Name  string
	Email string
}

// UserService 負責用戶數據庫操作
type UserService struct {
	db *sql.DB
}

// NewUserService 建立新的 UserService
func NewUserService(db *sql.DB) *UserService {
	return &UserService{db: db}
}

// GetUserByID 從數據庫獲取用戶
func (s *UserService) GetUserByID(id int) (*User, error) {
	var user User
	row := s.db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
	err := row.Scan(&user.ID, &user.Name, &user.Email)
	if err == sql.ErrNoRows {
		return nil, fmt.Errorf("user with ID %d not found", id)
	}
	if err != nil {
		return nil, fmt.Errorf("failed to query user: %w", err)
	}
	return &user, nil
}

// CreateUser 創建新用戶
func (s *UserService) CreateUser(name, email string) (int64, error) {
	result, err := s.db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", name, email)
	if err != nil {
		return 0, fmt.Errorf("failed to insert user: %w", err)
	}
	lastInsertID, err := result.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("failed to get last insert ID: %w", err)
	}
	return lastInsertID, nil
}

測試 UserService

package main

import (
	"database/sql"
	"errors"
	"testing"

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

func TestUserService(t *testing.T) {
	// 創建一個模擬數據庫連接和一個 mock 控制器
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
	}
	defer db.Close() // 確保在測試結束時關閉模擬數據庫連接

	service := NewUserService(db)

	t.Run("GetUserByID_Success", func(t *testing.T) {
		// 設定對 SELECT 查詢的預期
		rows := sqlmock.NewRows([]string{"id", "name", "email"}).
			AddRow(1, "John Doe", "[email protected]")

		mock.ExpectQuery("SELECT id, name, email FROM users WHERE id = ?").
			WithArgs(1).         // 預期接收 ID 為 1 的參數
			WillReturnRows(rows) // 返回預設的行

		user, err := service.GetUserByID(1)
		assert.NoError(t, err)
		assert.NotNil(t, user)
		assert.Equal(t, 1, user.ID)
		assert.Equal(t, "John Doe", user.Name)
		assert.Equal(t, "[email protected]", user.Email)

		// 檢查所有預期是否被滿足
		assert.NoError(t, mock.ExpectationsWereMet())
	})

	t.Run("GetUserByID_NotFound", func(t *testing.T) {
		// 設定對 SELECT 查詢的預期,但返回 sql.ErrNoRows
		mock.ExpectQuery("SELECT id, name, email FROM users WHERE id = ?").
			WithArgs(99).
			WillReturnError(sql.ErrNoRows) // 模擬找不到行的錯誤

		user, err := service.GetUserByID(99)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "user with ID 99 not found")
		assert.Nil(t, user)

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

	t.Run("CreateUser_Success", func(t *testing.T) {
		// 設定對 INSERT 執行語句的預期
		mock.ExpectExec("INSERT INTO users \\(name, email\\) VALUES \\(\\?, \\?\\)"). // 正則匹配 SQL
												WithArgs("Jane Smith", "[email protected]").
												WillReturnResult(sqlmock.NewResult(101, 1)) // 模擬返回 LastInsertId 為 101,影響行數為 1

		id, err := service.CreateUser("Jane Smith", "[email protected]")
		assert.NoError(t, err)
		assert.Equal(t, int64(101), id)

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

	t.Run("CreateUser_Failed", func(t *testing.T) {
		// 設定對 INSERT 執行語句的預期,但返回錯誤
		mock.ExpectExec("INSERT INTO users \\(name, email\\) VALUES \\(\\?, \\?\\)").
			WithArgs("Error User", "[email protected]").
			WillReturnError(errors.New("database connection failed")) // 模擬數據庫錯誤

		id, err := service.CreateUser("Error User", "[email protected]")
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "database connection failed")
		assert.Equal(t, int64(0), id)

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

運行方式:

  1. 將上述 UserUserService 和測試代碼放在同一個 Go 模組中。
  2. 執行 go test -v

uber-go/mock (與 gomock):自動生成 Mock 程式碼

當您的程式碼依賴於多個介面或介面方法複雜時,手動撰寫 Mock 物件會非常繁瑣且容易出錯。uber-go/mock 提供了 mockgen 工具,它可以根據您定義的 Go 介面自動生成 Mock 實現程式碼,極大地簡化了 Mocking 的過程。

主要用法:

  1. 定義介面: 首先,您的程式碼必須有明確定義的介面。
  2. 在測試中使用:
    • 創建 gomock.NewController(t)
    • 使用 NewMockInterface(ctrl) 創建 Mock 實例。
    • 使用 mock.EXPECT().Method(...) 設定預期調用,並使用 Return(...)DoAndReturn(...) 定義返回值或行為。
    • 測試結束時,defer ctrl.Finish() 會自動驗證所有預期是否被滿足。

生成 Mock 程式碼: 運行 mockgen 命令來生成 Mock 檔案。

mockgen -source=your/package/interface.go -destination=path/to/mocks/mock_interface.go -package=mocks

例如,如果您的介面在 services/user.go,並且您希望 Mock 檔案在 mocks/mock_user_service.go:Bash

mockgen -source=services/user.go -destination=mocks/mock_user_service.go -package=mocks

安裝 mockgen

go install go.uber.org/mock/mockgen@latest
範例:用戶服務介面

首先,定義一個服務介面: services/user_service.go

package services

import "context"

// User 結構體
type User struct {
	ID   int
	Name string
}

// UserService 是一個定義用戶操作的介面
type UserService interface {
	GetUser(ctx context.Context, id int) (*User, error)
	CreateUser(ctx context.Context, name string) (int, error)
	DeleteUser(ctx context.Context, id int) error
}

接下來,生成 Mock 程式碼:

mockgen -source=services/user_service.go -destination=mocks/mock_user_service.go -package=mocks

這將在 mocks 目錄下創建 mock_user_service.go 文件。

現在,我們來撰寫一個依賴 UserService 的業務邏輯,並使用生成的 Mock 進行測試:

package main

import (
	"context"
	"errors"
	"fmt"

	"test/services"
)

// UserProcessor 是一個處理用戶相關業務邏輯的結構
type UserProcessor struct {
	userService services.UserService
}

// NewUserProcessor 建立新的 UserProcessor
func NewUserProcessor(us services.UserService) *UserProcessor {
	return &UserProcessor{userService: us}
}

// GetAndLogUser 獲取用戶並記錄其名稱
func (p *UserProcessor) GetAndLogUser(ctx context.Context, id int) (string, error) {
	user, err := p.userService.GetUser(ctx, id)
	if err != nil {
		return "", fmt.Errorf("failed to get user: %w", err)
	}
	if user == nil {
		return "", errors.New("user not found")
	}
	fmt.Printf("User found: ID=%d, Name=%s\n", user.ID, user.Name)
	return user.Name, nil
}

// ProcessNewUser 處理新用戶的創建
func (p *UserProcessor) ProcessNewUser(ctx context.Context, name string) (int, error) {
	if name == "" {
		return 0, errors.New("user name cannot be empty")
	}
	userID, err := p.userService.CreateUser(ctx, name)
	if err != nil {
		return 0, fmt.Errorf("failed to create user: %w", err)
	}
	fmt.Printf("User created with ID: %d\n", userID)
	return userID, nil
}

測試 UserProcessor

package main

import (
	"context"
	"errors"
	"testing"

	"test/mocks"
	"test/services"

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

func TestUserProcessor(t *testing.T) {
	// 1. 建立 gomock 控制器,它管理 Mock 物件的生命週期
	ctrl := gomock.NewController(t)
	// 確保所有預期調用都在測試結束時被滿足,否則會報錯
	defer ctrl.Finish()

	// 2. 使用生成的 Mock 程式碼建立 Mock 實例
	mockUserService := mocks.NewMockUserService(ctrl)
	processor := NewUserProcessor(mockUserService) // 將 Mock 注入到 UserProcessor

	ctx := context.Background()

	t.Run("GetAndLogUser_Success", func(t *testing.T) {
		// 3. 設定 Mock 預期:當 GetUser 方法被調用時,期望的參數和返回值
		// gomock.Any() 表示匹配任何參數值
		// gomock.Eq(123) 表示參數必須等於 123
		mockUserService.EXPECT().
			GetUser(gomock.Any(), gomock.Eq(1)).               // 期望 GetUser 被調用,ctx 為任意值,id 為 1
			Return(&services.User{ID: 1, Name: "Alice"}, nil). // 返回一個模擬用戶和 nil 錯誤
			Times(1)                                           // 期望該方法被調用一次

		name, err := processor.GetAndLogUser(ctx, 1)
		assert.NoError(t, err)
		assert.Equal(t, "Alice", name)
	})

	t.Run("GetAndLogUser_UserNotFound", func(t *testing.T) {
		mockUserService.EXPECT().
			GetUser(gomock.Any(), gomock.Eq(99)).
			Return(nil, errors.New("not found")). // 模擬返回找不到用戶的錯誤
			AnyTimes()                            // 演示 AnyTimes,表示該預期可以被調用任意次 (0 次或多次)

		name, err := processor.GetAndLogUser(ctx, 99)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "failed to get user")
		assert.Empty(t, name)
	})

	t.Run("ProcessNewUser_Success", func(t *testing.T) {
		mockUserService.EXPECT().
			CreateUser(gomock.Any(), gomock.Eq("Bob")).
			Return(2, nil) // 模擬返回創建成功的用戶 ID 2

		userID, err := processor.ProcessNewUser(ctx, "Bob")
		assert.NoError(t, err)
		assert.Equal(t, 2, userID)
	})

	t.Run("ProcessNewUser_EmptyNameError", func(t *testing.T) {
		// 這個測試不應該觸發 CreateUser,所以不需要設定 Mock 預期
		userID, err := processor.ProcessNewUser(ctx, "")
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "user name cannot be empty")
		assert.Equal(t, 0, userID)
	})
}

運行方式:

  1. your_project 目錄下執行 go test -v ./...

確保您的專案結構如下,並將上述程式碼放入對應文件:

your_project/
├── main.go            # 假設這裡放 UserProcessor 相關邏輯
├── services/
│   └── user_service.go
└── mocks/
    └── mock_user_service.go # 由 mockgen 生成

uber-go/mock 配合 gomock 極大地提高了 Go 語言中介面 Mocking 的效率和可靠性,尤其適用於複雜的介面和大型專案。


Go 語言開發者工具:silenceper/gowatch

在 Go 語言開發過程中,一個常見的痛點是每次修改程式碼後,都需要手動停止正在運行的應用程式,然後重新編譯並啟動,這在快速迭代和調試時會顯得重複而繁瑣。為了解決這個問題,silenceper/gowatch 應運而生。

silenceper/gowatch 是一個簡單而實用的 Go 專案監聽器,它能夠自動監測您的程式碼文件變動,並在檢測到變動後,自動重新編譯並運行您的 Go 應用程式。這極大地提升了開發效率,讓您可以專注於程式碼撰寫本身,而無需頻繁地在終端機中執行編譯和運行命令。

主要特色:

  1. 文件監測與自動重啟: gowatch 會實時監聽指定目錄下的 Go 源碼文件(.go 文件)的變動。一旦文件被修改、新增或刪除,它就會自動觸發重新編譯和運行應用程式。
  2. 輕量級與易於使用: 安裝和使用都非常簡單,只需要一個命令即可啟動。
  3. 可配置性: 允許您配置要監聽的目錄、要忽略的文件類型、重新編譯時的參數等。
  4. 日誌輸出: 會在終端機中清晰地輸出重新編譯和運行過程的日誌。

安裝方式:

安裝 gowatch 非常簡單,只需使用 go install 命令:

go install github.com/silenceper/gowatch@latest

安裝完成後,gowatch 命令應該就可以在您的終端機中使用了。

基本用法範例:

假設您有一個簡單的 Go Web 應用程式 main.go

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello, Go Watch! Current time: %s\n", time.Now().Format("2006-01-02 15:04:05"))
	})

	port := ":8080"
	log.Printf("Server starting on port %s", port)
	log.Fatal(http.ListenAndServe(port, nil))
}

要使用 gowatch 監聽這個應用程式並自動重啟:

  1. 導航到專案目錄:
    在終端機中,進入包含 main.go 文件的目錄。

2. 執行 gowatch gowatch
您將會看到類似以下的輸出:

2025/06/02 13:52:45 gowatch.go:75: [INFO] Initializing watcher...
2025/06/02 13:52:45 gowatch.go:77: [INFO] Directory( /Users/wei/Documents/test )
2025/06/02 13:52:45 gowatch.go:121: [INFO] Start building...
2025/06/02 13:52:46 gowatch.go:166: [INFO] Build Args: go build -o ./test
2025/06/02 13:52:46 gowatch.go:174: [INFO] Build was successful
2025/06/02 13:52:46 gowatch.go:307: [INFO] Restarting ./test ...
2025/06/02 13:52:46 gowatch.go:317: [INFO] Run ./test
2025/06/02 13:52:46 gowatch.go:322: [INFO] ./test is running...
2025/06/02 13:52:46 Server starting on port :8080

您可以嘗試修改 main.go 中的輸出訊息,保存後,再次訪問 http://localhost:8080,您會發現訊息已經更新,而您並沒有手動停止和啟動服務器。


Golang 應用程式 Docker Image 最佳實踐

正確打包 Go 應用程式的 Docker Image 最佳方式是使用多階段構建 (Multi-stage Builds),這能顯著減小最終映像檔大小,提高安全性和部署效率。

範例:Golang 應用程式的多階段 Dockerfile
# --- Build Stage ---
# 1. 使用官方 Go 映像檔作為構建階段的基礎
# 建議使用帶有特定版本號的映像檔 (e.g., golang:1.23.4-bookworm),避免使用 'latest'
FROM golang:1.23.4-bookworm AS builder

# 設定工作目錄
WORKDIR /src

# 將 Go 模組文件複製到工作目錄,並下載依賴,以利用 Docker 層的緩存
COPY go.mod go.sum ./
RUN go mod download

# 將所有源碼複製到工作目錄
COPY . .

# 編譯應用程式
# CGO_ENABLED=0 確保生成完全靜態鏈接的二進制文件,這樣最終映像檔就不需要 C 語言運行時庫
# -ldflags "-s -w" 移除符號表和調試信息,進一步減小二進制文件大小
# 這裡將編譯和運行放在一個 RUN 命令中,以限制映像檔層數
RUN go build -o myapp -ldflags="-s -w" .


# --- Run Stage ---
# 2. 使用一個極小的基礎映像檔來運行應用程式
# 圖片中提到使用 debian:bookworm-slim
FROM debian:bookworm-slim

# 在此階段也設定工作目錄
WORKDIR /app

# 由於 debian:bookworm-slim 預設不包含 ca-certificates,而 Go 應用程式通常需要 SSL/TLS 連接,
# 我們需要手動安裝它。
# 將 apt-get update 與 apt-get install 放在同一個 RUN 命令中,減少層數並避免陳舊緩存
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
    rm -rf /var/lib/apt/lists/*

# 3. 設定非 root 用戶運行應用程式以增強安全性
# 創建非 root 用戶和組,並使用靜態的 UID 和 GID
# 避免使用 UID 低於 10000 的用戶
ARG GID=10000
ARG UID=10001
RUN groupadd -g $GID appgroup && useradd -u $UID -g appgroup -s /bin/bash appuser
# 注意: 圖片中創建用戶的命令略有不同,這裡使用更常見且較完整的做法,確保用戶有 shell。
# 圖片中示例: RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 這裡的範例: RUN groupadd -g $GID appgroup && useradd -u $UID -g appgroup -s /bin/bash appuser

# 將構建階段編譯好的二進制文件複製到最終映像檔
# 使用 chown 將複製的文件的所有權變更為非 root 用戶
COPY --from=builder /src/myapp /app/myapp
RUN chown ${UID}:${GID} /app/myapp # 確保二進制文件由正確的用戶所有

# 將工作目錄設置為 appuser,之後的所有命令都將以該用戶身份運行
USER appuser

# 暴露應用程式監聽的端口 (如果適用)
EXPOSE 8080

# 定義容器啟動時執行的命令
# 只存儲參數在 CMD 中
ENTRYPOINT ["./myapp"]

最佳實踐說明:

  1. 使用官方 Docker 映像檔 (FROM golang:1.23.4-bookworm AS builder)
    • 選擇官方維護的映像檔可以確保環境的穩定性和優化。
    • 指定特定映像檔標籤:避免使用 latest,應指定精確的版本號(例如 golang:1.23.4-bookworm),以確保構建的可重複性和穩定性。
  2. 利用 Docker 層緩存
    • 優先複製 go.modgo.sum 並下載依賴。這樣,只有當依賴發生變化時,這一層才會被重新構建,否則會利用緩存,加速構建過程。
  3. 使用極小的運行時映像檔 (FROM debian:bookworm-slim)
    • debian:bookworm-slim 是一個基於 Debian 的輕量級映像檔,相比於完整的 Debian 映像檔更小,同時提供了比 alpine 更廣泛的工具鏈兼容性,對於需要特定 glibc 兼容性的應用可能更有利。
  4. 限制映像檔層數 (RUN apt-get update && apt-get install ...)
    • 將多個相關的命令(如 apt-get updateapt-get install)合併到單個 RUN 語句中,可以減少映像檔層數,從而減小最終映像檔的大小並提升構建效率。
  5. 生成靜態鏈接的二進制文件 (CGO_ENABLED=0 go build ...)
    • CGO_ENABLED=0 確保 Go 編譯器不會鏈接任何 C 語言庫,生成一個完全獨立的二進制文件。
    • -ldflags="-s -w" 移除符號表和調試信息,可以顯著減小編譯後的二進制文件大小。這對於最終的 Docker 映像檔至關重要。
  6. 運行非 Root 用戶 (USER appuser)
    • 出於安全考慮,強烈建議在容器內使用非 Root 用戶運行應用程式。這可以限制應用程式在容器內可能造成的損害。
    • 不要使用 UID 低於 10000 的用戶,並且使用靜態的 UID 和 GID 來創建用戶和組。範例中使用了 ARG GID=10000ARG UID=10001 來指定這些值。
  7. 只存儲參數在 ENTRYPOINT 中 (ENTRYPOINT ["./myapp"])
    • CMD 指令應該只包含可執行文件的參數。可執行文件本身可以通過 ENTRYPOINT 定義,這能確保應用程式作為 PID 1 運行,正確處理信號。
  8. 總是使用 COPY 而不是 ADD (COPY --from=builder /src/myapp /app/myapp)
    • COPYADD 更透明和可預測。ADD 具有額外功能(如自動解壓縮和從 URL 下載文件),但這些通常在構建映像檔時不需要,且可能引入不必要的複雜性和安全風險。

通過遵循這些最佳實踐,您可以構建出高效、安全且易於部署的 Go 應用程式 Docker 映像檔。


結語:提升 Go 語言開發效率與品質的旅程

本系列部落格文章旨在為 Go 語言開發者提供一系列實用且高效的工具和最佳實踐,從依賴管理到測試,再到 Docker 部署,全面提升您的開發體驗和應用程式品質。

我們首先深入探討了 uber-go/fx 框架,它透過依賴注入的模式,幫助我們實現應用程式的模組化與可維護性。我們學習了如何使用 fx.Provide 來聲明組件,並透過 fx.Invoke 來啟動應用邏輯,以及如何利用 fx.Lifecycle 實現服務的優雅啟停。這使得大型 Go 應用程式的架構更加清晰,各組件之間的耦合度更低。

接著,我們轉向了確保程式碼可靠性的核心環節:測試。我們比較了 Go 語言社群中最受歡迎的三個測試框架:stretchr/testifysmartystreets/goconveyonsi/ginkgo

  • stretchr/testify 以其豐富的斷言和內建的 Mocking 功能,成為標準 testing 包的強大擴展,提升了測試程式碼的可讀性和效率.
  • smartystreets/goconvey 則透過其 BDD 風格的語法和獨特的互動式網頁報告,為開發者帶來了直觀且快速的測試反饋體驗.
  • onsi/ginkgo 結合 onsi/gomega,提供了一套高度結構化的 BDD 框架,特別適合大型專案中複雜行為的測試描述,並支援高效的並行測試.

為了進一步強化單元測試的隔離性,我們介紹了三種不同類型的 Mocking 工具jarcoal/httpmockDATA-DOG/go-sqlmockuber-go/mock

  • jarcoal/httpmock 讓我們能夠輕鬆模擬 HTTP 請求和回應,無需依賴真實的外部 API.
  • DATA-DOG/go-sqlmock 則提供了模擬數據庫操作的能力,讓數據庫層的測試變得快速且穩定.
  • uber-go/mock(搭配 gomock)則透過自動生成 Mock 程式碼,解決了複雜介面手動 Mocking 的痛點,大幅提升了測試效率.

最後,我們聚焦於提升日常開發效率的工具 silenceper/gowatch。它能夠自動監測程式碼文件變動並觸發重新編譯和運行,讓開發者能夠擺脫手動操作的繁瑣,實現更快速的開發反饋循環。

我們也探討了如何正確地打包 Go 應用程式的 Docker Image,並強調了多階段構建的重要性。這項技術能確保最終映像檔最小化,只包含運行時所需的必要組件。此外,我們還結合了許多通用 Dockerfile 最佳實踐,例如使用官方映像檔、指定精確版本、利用層緩存、設置非 Root 用戶以及優化 RUNENTRYPOINT 指令等,以構建高效、安全且可維護的容器化應用程式.

本系列部落格貼文就分享到這邊。希望這些內容能夠幫助您在 Go 語言的開發旅程中更加得心應手,無論是提升程式碼品質、加速開發流程,還是優化部署策略,都能找到實用的指南和工具。感謝您的閱讀!