Go測試框架Testify實戰:2026年生產級單元測試模式與最佳實踐
當if err != nil遇上if reflect.DeepEqual:Go測試的痛苦輪迴
你寫過這樣的測試嗎?
func TestAdd(t *testing.T) {
result := Add(1, 2)
if result != 3 {
t.Errorf("Add(1, 2) = %d; want 3", result)
}
}
一個簡單的加法測試,看起來還行。但當你要測試slice相等、map比較、結構體深比較、錯誤型別匹配時,程式碼就變成了噩夢——reflect.DeepEqual滿天飛,錯誤訊息毫無可讀性,測試失敗後你只知道"not equal",卻不知道哪裡不等。更別提Go標準函式庫沒有內建Mock框架,介面模擬全靠手寫,測試組織全靠if堆砌。
這不是個別現象。在台積電、鴻海等大廠的Go專案中,沒有testify的測試程式碼往往比業務程式碼還長。本文將從5種生產級測試模式出發,帶你徹底掌握testify框架,讓Go測試從"能跑就行"進化到"專業可靠"。
核心概念速查
| 元件 | 用途 | 關鍵特性 | 典型場景 |
|---|---|---|---|
assert |
斷言並記錄失敗 | 失敗後繼續執行,收集所有錯誤 | 多項斷言的單元測試 |
require |
斷言並立即終止 | 失敗後停止當前測試,避免空指標級聯 | 前置條件檢查 |
suite |
測試套件組織 | Setup/TearDown生命週期,共享狀態 | 整合測試、資料庫測試 |
mock |
介面模擬 | 自動產生方法呼叫斷言 | 依賴解耦、外部服務模擬 |
httptest |
HTTP測試伺服器 | 真實HTTP請求模擬,無需啟動服務 | API Handler測試 |
Go測試的5大挑戰
挑戰1:斷言冗長且訊息不友好
func TestUserEqual(t *testing.T) {
got := GetUser(1)
want := User{ID: 1, Name: "張三", Age: 30}
if got.ID != want.ID {
t.Errorf("ID: got %d, want %d", got.ID, want.ID)
}
if got.Name != want.Name {
t.Errorf("Name: got %s, want %s", got.Name, want.Name)
}
if got.Age != want.Age {
t.Errorf("Age: got %d, want %d", got.Age, want.Age)
}
}
三個欄位就要寫三段if,十個欄位呢?巢狀結構體呢?reflect.DeepEqual雖然能比較,但失敗訊息只有not equal,你根本不知道哪個欄位不對。
挑戰2:沒有內建Mock,手寫模擬程式碼爆炸
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id int64) error
List(ctx context.Context, filter Filter) ([]*User, error)
}
5個方法的介面,手寫Mock要實作5個方法,即使某個測試只用到1個。10個介面就是50個方法實作,Mock程式碼量比業務程式碼還多。
挑戰3:測試組織混亂,Setup/TearDown重複
每個測試都要db.Connect(),每個測試結束都要db.Close(),還要清理測試資料。複製貼上滿天飛,漏了TearDown就污染下一個測試。
挑戰4:表驅動測試的斷言困境
表驅動測試是Go的慣用模式,但標準函式庫的斷言在表驅動測試中表現糟糕——t.Errorf不會告訴你具體是哪一行測試案例失敗,你只能靠手動新增case名稱來區分。
挑戰5:HTTP測試依賴真實服務
測試API Handler需要啟動HTTP伺服器?還是用httptest.NewRecorder?請求體怎麼構造?回應怎麼斷言?沒有統一的模式,團隊裡十個人寫出十種風格。
模式1:assert與require——斷言的兩種哲學
assert vs require 核心區別
package user_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAssertVsRequire(t *testing.T) {
// assert: 失敗後繼續執行,收集所有錯誤
assert.Equal(t, 1, 2, "第一個斷言")
assert.Equal(t, 3, 3, "第二個斷言") // 仍然會執行
assert.NotNil(t, nil, "第三個斷言") // 仍然會執行
// 測試結果:3個失敗,你一次看到所有問題
}
func TestRequireBehavior(t *testing.T) {
// require: 失敗後立即終止當前測試
require.Equal(t, 1, 2, "前置條件不滿足")
// 下面這行永遠不會執行
require.Equal(t, 3, 3, "不會到這裡")
}
生產級斷言實戰
package service_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"myapp/model"
"myapp/service"
)
func TestCreateUser(t *testing.T) {
// require用於前置條件:建立必須成功,否則後續斷言無意義
user, err := service.CreateUser("zhangsan", "zhang@example.com")
require.NoError(t, err, "建立使用者不應回傳錯誤")
require.NotNil(t, user, "建立使用者不應回傳nil")
// assert用於業務斷言:即使某個欄位不對,也想看到其他欄位的結果
assert.Equal(t, "zhangsan", user.Name, "使用者名稱應匹配")
assert.Equal(t, "zhang@example.com", user.Email, "信箱應匹配")
assert.NotZero(t, user.ID, "使用者ID不應為零值")
assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Second, "建立時間應接近當前時間")
}
常用斷言方法速查
// 相等性
assert.Equal(t, expected, actual)
assert.NotEqual(t, expected, actual)
assert.Exactly(t, expected, actual) // 型別+值完全一致
// 布林判斷
assert.True(t, condition)
assert.False(t, condition)
// nil檢查
assert.Nil(t, obj)
assert.NotNil(t, obj)
// 錯誤檢查
assert.NoError(t, err)
assert.Error(t, err)
assert.ErrorIs(t, err, os.ErrNotExist)
assert.ErrorContains(t, err, "not found")
// 集合
assert.Contains(t, slice, element)
assert.NotContains(t, slice, element)
assert.ElementsMatch(t, []int{1,2,3}, []int{3,1,2}) // 無序匹配
assert.Subset(t, []int{1,2,3,4}, []int{2,3})
// 型別檢查
assert.IsType(t, &User{}, actual)
assert.Implements(t, (*Repository)(nil), actual)
// 數值比較
assert.Greater(t, 10, 5)
assert.LessOrEqual(t, 5, 5)
assert.InDelta(t, 3.14, 3.14159, 0.01)
// 字串
assert.Empty(t, "")
assert.Regexp(t, `^\d+$`, "12345")
模式2:表驅動測試 + testify suite
基礎表驅動測試
package calculator_test
import (
"testing"
"github.com/stretchr/testify/assert"
"myapp/calculator"
)
func TestDivide(t *testing.T) {
tests := []struct {
name string
a float64
b float64
want float64
wantErr bool
errContains string
}{
{
name: "正常除法",
a: 10.0,
b: 2.0,
want: 5.0,
wantErr: false,
},
{
name: "除以零",
a: 10.0,
b: 0.0,
wantErr: true,
errContains: "division by zero",
},
{
name: "負數除法",
a: -10.0,
b: 2.0,
want: -5.0,
wantErr: false,
},
{
name: "浮點精度",
a: 1.0,
b: 3.0,
want: 0.333333,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := calculator.Divide(tt.a, tt.b)
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
return
}
assert.NoError(t, err)
assert.InDelta(t, tt.want, got, 0.001)
})
}
}
testify suite:帶生命週期的測試套件
package repository_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
"myapp/model"
"myapp/repository"
"myapp/testutil"
)
type UserRepositorySuite struct {
suite.Suite // 嵌入suite.Suite,獲得assert/require方法
db *testutil.TestDB
repo repository.UserRepository
ctx context.Context
}
// SetupSuite 在所有測試之前執行一次
func (s *UserRepositorySuite) SetupSuite() {
s.db = testutil.NewTestDB()
s.db.Migrate()
s.ctx = context.Background()
}
// TearDownSuite 在所有測試之後執行一次
func (s *UserRepositorySuite) TearDownSuite() {
s.db.Close()
}
// SetupTest 在每個測試之前執行
func (s *UserRepositorySuite) SetupTest() {
s.db.TruncateTables()
s.repo = repository.NewUserRepository(s.db.Conn())
}
// TearDownTest 在每個測試之後執行
func (s *UserRepositorySuite) TearDownTest() {
// 清理測試產生的快取等
}
func (s *UserRepositorySuite) TestCreate() {
user := &model.User{
Name: "張三",
Email: "zhang@example.com",
}
err := s.repo.Create(s.ctx, user)
s.NoError(err)
s.NotZero(user.ID)
found, err := s.repo.GetByID(s.ctx, user.ID)
s.NoError(err)
s.Equal("張三", found.Name)
s.Equal("zhang@example.com", found.Email)
}
func (s *UserRepositorySuite) TestGetByIDNotFound() {
_, err := s.repo.GetByID(s.ctx, 99999)
s.Error(err)
s.ErrorIs(err, repository.ErrNotFound)
}
func (s *UserRepositorySuite) TestList() {
users := []*model.User{
{Name: "張三", Email: "zhang@example.com"},
{Name: "李四", Email: "li@example.com"},
{Name: "王五", Email: "wang@example.com"},
}
for _, u := range users {
s.NoError(s.repo.Create(s.ctx, u))
}
result, err := s.repo.List(s.ctx, repository.Filter{Limit: 10})
s.NoError(err)
s.Len(result, 3)
}
// 入口函式:必須呼叫suite.Run
func TestUserRepositorySuite(t *testing.T) {
suite.Run(t, new(UserRepositorySuite))
}
模式3:Mock與介面模擬
testify/mock 基礎用法
package service_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"myapp/model"
"myapp/service"
)
// MockUserRepository 模擬UserRepository介面
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetByID(ctx context.Context, id int64) (*model.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.User), args.Error(1)
}
func (m *MockUserRepository) Create(ctx context.Context, user *model.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepository) Update(ctx context.Context, user *model.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepository) Delete(ctx context.Context, id int64) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockUserRepository) List(ctx context.Context, filter repository.Filter) ([]*model.User, error) {
args := m.Called(ctx, filter)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*model.User), args.Error(1)
}
業務層測試:驗證呼叫行為
func TestUserService_GetUser(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := service.NewUserService(mockRepo)
t.Run("使用者存在", func(t *testing.T) {
expectedUser := &model.User{ID: 1, Name: "張三", Email: "zhang@example.com"}
mockRepo.On("GetByID", mock.Anything, int64(1)).
Return(expectedUser, nil).Once()
user, err := svc.GetUser(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, "張三", user.Name)
mockRepo.AssertExpectations(t)
})
t.Run("使用者不存在", func(t *testing.T) {
mockRepo.On("GetByID", mock.Anything, int64(999)).
Return(nil, repository.ErrNotFound).Once()
_, err := svc.GetUser(context.Background(), 999)
assert.ErrorIs(t, err, repository.ErrNotFound)
mockRepo.AssertExpectations(t)
})
}
func TestUserService_CreateUser_DuplicateEmail(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := service.NewUserService(mockRepo)
mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(u *model.User) bool {
return u.Email == "duplicate@example.com"
})).Return(repository.ErrDuplicateEmail).Once()
err := svc.CreateUser(context.Background(), "張三", "duplicate@example.com")
assert.ErrorIs(t, err, repository.ErrDuplicateEmail)
mockRepo.AssertExpectations(t)
}
Mock進階用法:匹配器與呼叫次數
func TestUserService_ComplexMock(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := service.NewUserService(mockRepo)
mockRepo.On("Create",
mock.Anything,
mock.MatchedBy(func(u *model.User) bool {
return u.Name != "" && u.Email != ""
}),
).Return(nil).Once()
mockRepo.On("Delete", mock.Anything, mock.Anything).Return(nil).Times(0)
err := svc.CreateUser(context.Background(), "張三", "zhang@example.com")
require.NoError(t, err)
mockRepo.AssertExpectations(t)
mockRepo.AssertCalled(t, "Create", mock.Anything, mock.Anything)
mockRepo.AssertNumberOfCalls(t, "Create", 1)
mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything)
}
模式4:HTTP Handler測試
使用httptest測試Gin Handler
package handler_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"myapp/handler"
"myapp/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestGetUserHandler(t *testing.T) {
mockSvc := new(MockUserService)
h := handler.NewUserHandler(mockSvc)
router := gin.New()
router.GET("/api/v1/users/:id", h.GetUser)
t.Run("成功取得使用者", func(t *testing.T) {
expectedUser := &model.User{ID: 1, Name: "張三", Email: "zhang@example.com"}
mockSvc.On("GetUser", mock.Anything, int64(1)).Return(expectedUser, nil).Once()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/users/1", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp struct {
Code int `json:"code"`
Data *model.User `json:"data"`
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, 0, resp.Code)
assert.Equal(t, "張三", resp.Data.Name)
mockSvc.AssertExpectations(t)
})
t.Run("使用者不存在回傳404", func(t *testing.T) {
mockSvc.On("GetUser", mock.Anything, int64(999)).
Return(nil, service.ErrUserNotFound).Once()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/users/999", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
mockSvc.AssertExpectations(t)
})
}
func TestCreateUserHandler(t *testing.T) {
mockSvc := new(MockUserService)
h := handler.NewUserHandler(mockSvc)
router := gin.New()
router.POST("/api/v1/users", h.CreateUser)
t.Run("建立成功", func(t *testing.T) {
mockSvc.On("CreateUser", mock.Anything, "張三", "zhang@example.com").
Return(&model.User{ID: 1, Name: "張三", Email: "zhang@example.com"}, nil).Once()
body := `{"name":"張三","email":"zhang@example.com"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
mockSvc.AssertExpectations(t)
})
t.Run("參數校驗失敗", func(t *testing.T) {
body := `{"name":"","email":"invalid"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
httptest.NewServer:模擬外部HTTP服務
package client_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"myapp/client"
)
func TestWeatherClient_GetWeather(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/weather", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
assert.Equal(t, "Beijing", r.URL.Query().Get("city"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"city":"Beijing","temp":25,"humidity":60}`))
}))
defer server.Close()
c := client.NewWeatherClient(server.URL, "test-token")
weather, err := c.GetWeather(context.Background(), "Beijing")
require.NoError(t, err)
assert.Equal(t, "Beijing", weather.City)
assert.Equal(t, 25, weather.Temp)
assert.Equal(t, 60, weather.Humidity)
}
模式5:測試套件組織與CI整合
專案測試目錄結構
myapp/
├── internal/
│ ├── user/
│ │ ├── service.go
│ │ ├── service_test.go
│ │ ├── handler.go
│ │ ├── handler_test.go
│ │ ├── mock_repository.go
│ │ └── testdata/
│ │ ├── create_user_req.json
│ │ └── get_user_resp.json
│ └── order/
│ ├── service.go
│ ├── service_test.go
│ └── suite_test.go
├── integration/
│ └── user_repository_test.go
├── testutil/
│ ├── db.go
│ └── fixture.go
├── Makefile
└── .github/
└── workflows/
└── test.yml
Makefile測試命令
.PHONY: test test-unit test-integration test-coverage test-race
test-unit:
go test -v -count=1 -short ./internal/...
test-integration:
go test -v -count=1 -tags=integration ./integration/...
test-race:
go test -v -race -count=1 ./...
test-coverage:
go test -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -html=coverage.out -o coverage.html
@echo "覆蓋率: $$(go tool cover -func=coverage.out | grep total | awk '{print $$3}')"
test: test-unit test-coverage
GitHub Actions CI設定
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.24', '1.25']
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }}
- name: Download dependencies
run: go mod download
- name: Vet
run: go vet ./...
- name: Test with race detector
run: go test -race -count=1 -short ./...
- name: Generate coverage
run: |
go test -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage.out
測試套件整合範例
package integration_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
"myapp/model"
"myapp/repository"
"myapp/testutil"
)
type IntegrationSuite struct {
suite.Suite
db *testutil.TestDB
repos *repository.Repositories
}
func (s *IntegrationSuite) SetupSuite() {
s.db = testutil.NewTestDB()
s.db.Migrate()
s.repos = repository.NewRepositories(s.db.Conn())
}
func (s *IntegrationSuite) TearDownSuite() {
s.db.Close()
}
func (s *IntegrationSuite) SetupTest() {
s.db.TruncateTables()
}
func (s *IntegrationSuite) TestUserCRUD() {
ctx := context.Background()
user := &model.User{Name: "張三", Email: "zhang@example.com"}
s.NoError(s.repos.User.Create(ctx, user))
s.NotZero(user.ID)
found, err := s.repos.User.GetByID(ctx, user.ID)
s.NoError(err)
s.Equal("張三", found.Name)
found.Name = "李四"
s.NoError(s.repos.User.Update(ctx, found))
updated, _ := s.repos.User.GetByID(ctx, user.ID)
s.Equal("李四", updated.Name)
s.NoError(s.repos.User.Delete(ctx, user.ID))
_, err = s.repos.User.GetByID(ctx, user.ID)
s.Error(err)
}
func TestIntegrationSuite(t *testing.T) {
suite.Run(t, new(IntegrationSuite))
}
5大常見陷阱
陷阱1:在表驅動測試中用require導致後續斷言被跳過
❌ 錯誤寫法:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := DoSomething(tt.input)
require.NoError(t, err) // 失敗後跳過後續斷言
require.Equal(t, tt.want, result)
})
}
✅ 正確寫法:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := DoSomething(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.want, result)
})
}
注意:
t.Run建立了獨立的子測試,require在子測試中終止的只是該子測試,不會影響其他案例。但在單個子測試內部,如果你還想看到後續斷言結果,應使用assert。
陷阱2:忘記呼叫AssertExpectations
❌ 錯誤寫法:
func TestWithMock(t *testing.T) {
m := new(MockRepo)
m.On("Get", mock.Anything, 1).Return(nil, nil)
// 沒有AssertExpectations!
}
✅ 正確寫法:
func TestWithMock(t *testing.T) {
m := new(MockRepo)
m.On("Get", mock.Anything, 1).Return(nil, nil).Once()
// ... 使用m
m.AssertExpectations(t)
}
陷阱3:Mock回傳值型別不匹配導致panic
❌ 錯誤寫法:
m.On("GetByID", mock.Anything, int64(1)).Return(nil, nil)
// nil型別歧義,可能導致panic
✅ 正確寫法:
m.On("GetByID", mock.Anything, int64(1)).Return(&model.User{ID: 1}, nil)
// 明確回傳具體型別的值
陷阱4:suite中直接使用t.Run
❌ 錯誤寫法:
func (s *MySuite) TestSomething() {
s.T().Run("subtest", func(t *testing.T) {
s.Equal(1, 2) // s.T()可能指向錯誤的t
})
}
✅ 正確寫法:
func (s *MySuite) TestSomething() {
s.Equal(1, 1)
s.NoError(nil)
}
// 或者拆分為獨立的測試方法
func (s *MySuite) TestSomethingCase1() {
s.Equal(1, 1)
}
func (s *MySuite) TestSomethingCase2() {
s.Equal(2, 2)
}
陷阱5:httptest中忘記設定Content-Type
❌ 錯誤寫法:
req, _ := http.NewRequest("POST", "/api/users", strings.NewReader(body))
// 沒有設定Content-Type
router.ServeHTTP(w, req)
✅ 正確寫法:
req, _ := http.NewRequest("POST", "/api/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
錯誤排查速查表
| 錯誤訊息 | 原因 | 解決方案 |
|---|---|---|
assert: arguments: Call may need to use WithArgs |
Mock的On方法參數型別與實際呼叫不匹配 | 確保On中的參數型別與實際呼叫一致,使用mock.Anything匹配任意參數 |
mock: Unexpected Method Call |
呼叫了未透過On註冊的Mock方法 | 檢查是否遺漏了On註冊,或新增.Maybe()標記可選呼叫 |
panic: interface conversion: interface {} is nil, not *X |
Mock Return的nil無法轉換為具體型別 | 使用Return(&X{}, nil)替代Return(nil, nil) |
suite: test method should have signature TestXxx(t *testing.T) |
suite入口函式簽名錯誤 | 確保入口函式為func TestXxxSuite(t *testing.T)並呼叫suite.Run |
cannot use m (type *MockRepo) as type Repo in assignment |
Mock結構體未完整實作介面 | 使用mockgen工具自動產生Mock,確保所有介面方法都已實作 |
test timed out after 10m0s |
Mock方法阻塞等待,但未被呼叫 | 檢查業務邏輯是否正確呼叫了Mock方法,或新增.Maybe() |
race detected during execution of test |
測試中存在並行資料競爭 | 使用-race旗標執行測試,檢查共享狀態的存取 |
testify: AssertExpectations failed |
註冊的Mock方法未被呼叫 | 檢查業務邏輯是否遺漏了方法呼叫,或使用.Maybe()標記可選呼叫 |
invalid argument for Int method |
Mock參數使用了錯誤型別 | 使用int64(1)而非1,確保參數型別與介面簽名一致 |
go: module github.com/stretchr/testify not found |
未安裝testify依賴 | 執行go get github.com/stretchr/testify@v1.9.0 |
進階最佳化技巧
技巧1:自訂斷言減少重複程式碼
package testutil
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"myapp/model"
)
// AssertUserEqual 自訂使用者斷言,忽略ID和CreatedAt
func AssertUserEqual(t *testing.T, expected, actual *model.User, msgAndArgs ...interface{}) {
t.Helper()
assert.Equal(t, expected.Name, actual.Name, msgAndArgs...)
assert.Equal(t, expected.Email, actual.Email, msgAndArgs...)
assert.Equal(t, expected.Age, actual.Age, msgAndArgs...)
if expected.Status != "" {
assert.Equal(t, expected.Status, actual.Status, msgAndArgs...)
}
}
// AssertTimeApproximately 時間近似斷言
func AssertTimeApproximately(t *testing.T, expected, actual time.Time, delta time.Duration, msgAndArgs ...interface{}) {
t.Helper()
assert.WithinDuration(t, expected, actual, delta, msgAndArgs...)
}
技巧2:使用mockgen自動產生Mock
# 安裝mockgen
go install go.uber.org/mock/mockgen@latest
# 為介面產生Mock
mockgen -source=repository.go -destination=mock_repository.go -package=repository_test
# 使用註解標記自動產生
//go:generate mockgen -source=repository.go -destination=mock_repository.go -package=repository_test
技巧3:測試子集執行與標籤
// 使用build tag控制整合測試
//go:build integration
package integration_test
import "testing"
func TestDatabaseCRUD(t *testing.T) {
// 只在 go test -tags=integration 時執行
}
# 只執行單元測試
go test -short ./...
# 執行整合測試
go test -tags=integration ./...
技巧4:平行測試加速
func TestParallel(t *testing.T) {
t.Parallel()
tests := []struct{ name string }{
{name: "case1"},
{name: "case2"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// 測試邏輯
})
}
}
技巧5:Golden File測試模式
package handler_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetUserResponse(t *testing.T) {
handler := setupHandler()
resp := handler.GetUser(context.Background(), 1)
actual, err := json.MarshalIndent(resp, "", " ")
require.NoError(t, err)
goldenPath := filepath.Join("testdata", "get_user_response.json")
if os.Getenv("UPDATE_GOLDEN") != "" {
err = os.WriteFile(goldenPath, actual, 0644)
require.NoError(t, err)
}
expected, err := os.ReadFile(goldenPath)
require.NoError(t, err)
assert.Equal(t, string(expected), string(actual))
}
# 更新golden檔案
UPDATE_GOLDEN=1 go test ./handler/...
測試框架對比
| 特性 | testify | gomock | gocheck | 標準testing |
|---|---|---|---|---|
| 斷言豐富度 | ⭐⭐⭐⭐⭐ 100+斷言方法 | ⭐⭐ 基本斷言 | ⭐⭐⭐ 較豐富 | ⭐ 僅t.Error |
| Mock支援 | ⭐⭐⭐⭐ 內建mock套件 | ⭐⭐⭐⭐⭐ mockgen自動產生 | ⭐⭐ 無內建 | ❌ 無 |
| 測試套件 | ⭐⭐⭐⭐ suite套件 | ❌ 無 | ⭐⭐⭐⭐⭐ Checker+Fixture | ⭐ 僅子測試 |
| 學習曲線 | ⭐ 低,API直觀 | ⭐⭐⭐ 中等,需學mockgen | ⭐⭐⭐ 中等 | ⭐ 最低 |
| 社群活躍度 | ⭐⭐⭐⭐⭐ GitHub 23k+ stars | ⭐⭐⭐⭐ Uber維護 | ⭐⭐ 較冷門 | ⭐⭐⭐⭐⭐ 官方 |
| 表驅動相容 | ⭐⭐⭐⭐⭐ 完美 | ⭐⭐⭐ 需適配 | ⭐⭐⭐ 需適配 | ⭐⭐⭐⭐ 原生支援 |
| HTTP測試 | ⭐⭐⭐⭐ 配合httptest | ⭐⭐⭐ 配合httptest | ⭐⭐⭐ 配合httptest | ⭐⭐⭐ 配合httptest |
| IDE支援 | ⭐⭐⭐⭐⭐ 全IDE支援 | ⭐⭐⭐⭐ 全IDE支援 | ⭐⭐ 部分支援 | ⭐⭐⭐⭐⭐ 全IDE支援 |
| 適用場景 | 通用測試,快速上手 | 介面Mock密集型專案 | 需要複雜Fixture的專案 | 簡單專案,極簡主義 |
總結
Go測試的核心問題不是"怎麼寫斷言",而是"怎麼組織測試"。assert和require解決了斷言的可讀性,suite解決了測試的生命週期管理,mock解決了依賴隔離,httptest解決了HTTP測試的可靠性,CI整合解決了測試的持續性。五者結合,才是生產級Go測試的完整方法論。記住:好的測試不是寫得多的測試,而是失敗時能精確定位問題的測試。
推薦工具
- JSON格式化工具 — 格式化API回應JSON,快速驗證測試斷言中的資料結構
- 雜湊計算工具 — 計算測試資料的簽章和校驗值,確保Mock資料一致性
- cURL轉程式碼工具 — 將cURL命令轉為Go HTTP測試程式碼,加速Handler測試編寫
本站提供瀏覽器本地工具,免註冊即可試用 →