單元測試的必要性與基礎
單元測試不僅是保障代碼質量的手段,也是優秀的設計工具和文檔形式,對軟件開發具有重要意義。
另一種形式的文檔:好的單元測試是一種活文檔,能清晰展示代碼單元的預期用途和行為,有時比注釋更有用。
提升API設計品味:編寫測試會促使開發者從使用者角度思考,推動設計出更簡潔、易用的API。難以測試的代碼往往是設計問題的早期信號,比如職責過多、耦合過緊或依賴關系混亂。
發現被忽略的角落:在編寫單元測試時,可能會發現之前未考慮到的邊界情況或特殊使用場景,從而完善代碼。
Go 語言單元測試基礎回顧
Go語言通過內置的 go test 命令和標準庫中的 testing 包提供了原生的測試支持,我們無需像其他語言一樣依賴復雜的第三方測試框架。
Go測試的"三板斧":_test.go文件、TestXxx函數和*testing.T
Go語言的測試遵循一套簡單而嚴格的約定,這使得?go test?工具能夠自動發現并執行測試:
測試文件命名:測試代碼必須放在以 _test.go 結尾的文件中。例如,如果你的業務代碼在 calculator.go 里,那么測試代碼就應該放在 calculator_test.go 中。go build 命令在編譯時會自動忽略這些測試文件。
測試文件位置:按照慣例,測試文件通常與被測試的代碼文件位于同一個包(同一個文件夾)內。
測試函數簽名:測試函數必須以 Test 開頭,并且后面跟一個首字母大寫的名稱(例如,TestMyFunction)。它只接受一個參數,即?t *testing.T。
*testing.T:這個 testing.T 類型的參數 t 是測試的“狀態表示器”,它提供了一系列方法用于報告測試失敗、記錄日志、控制測試流程等。
*testing.T 的常用方法:
t.Logf() / t.Log(): 記錄日志。當測試通過時,只有在執行 go test 時加上 -v 參數,這些日志才會顯示。
t.Errorf() / t.Error(): 報告測試失敗,但測試會繼續執行。這對于希望一次性看到所有失敗情況的場景很有用。
t.Fatalf() / t.Fatal(): 報告測試失敗,并 立即停止 當前測試函數的執行。
t.Skipf() / t.Skip(): 跳過當前測試。
t.Run(): 運行子測試。這對于組織相關的測試用例非常方便,是實現表驅動測試的核心。
t.Helper(): 將一個函數標記為測試輔助函數。這樣,當測試失敗時,日志會顯示調用該輔助函數的測試代碼行號,而不是輔助函數內部的行號,極大地提升了調試效率。
t.Parallel(): 將一個測試標記為可并行執行。這對于加速大型測試套件的執行時間很有幫助,但使用者需要注意并發安全問題。
表驅動測試:高效組織測試用例?
表驅動測試是 Go 社區推崇的一種清晰且可維護的測試方式。它并非工具或庫,而是一種代碼風格,通過將測試用例組織到一個“表”(通常是結構體切片)中,再用統一邏輯遍歷執行這些用例。
為什么推薦表驅動測試?
減少重復代碼:核心測試邏輯只需編寫一次,即可通用于所有用例。
提高可讀性:每個用例的輸入、期望輸出和描述都清晰地列在表中,一目了然。
易于維護:添加、修改或刪除測試用例,只需在表中增刪改一行即可。
清晰的失敗信息:結合 t.Run() 為每個子測試命名,一旦有測試失敗,能立刻知道是哪個用例掛了,以及具體的輸入和期望輸出是什么。
基本結構
定義一個結構體來描述你的測試用例。
type?addTestCase?struct?{\? ? name?string?// 測試用例名稱\? ? a, b?int?// 輸入參數\? ? want?int?// 期望輸出\? ? wantErr?bool?// 是否期望錯誤 (如果函數可能返回error)\}
創建一個該結構體的切片,并填充各種測試用例。
在測試函數中,遍歷這個切片,并為每個測試用例使用 t.Run 來創建一個子測試。
實戰演練:表驅動測試的應用示例
假設我們需要測試項目中的 processNumbers 函數,它接收一個整數切片,并返回其中所有正數的和。
// 文件: calculator.gopackage?main
// processNumbers 接收一個整數切片,并返回其中所有正數的和。// 如果切片為空,返回0。func?processNumbers(numbers []int)?int?{? ? sum :=?0? ??for?_, num :=?range?numbers {? ? ? ??if?num >?0?{? ? ? ? ? ? sum += num? ? ? ? }? ? }? ??return?sum}
下面是為其編寫的表驅動單元測試:
// 文件: calculator_test.gopackage?main
import?"testing"
func?TestProcessNumbers_TableDriven(t *testing.T)?{// 1. 定義測試用例結構體 tests := []struct?{ name ? ?string numbers []int want ? ?int?// 期望正數和 }{ { name: ? ?"空切片應返回0", numbers: []int{}, want: ? ?0, }, { name: ? ?"所有數字均為正數", numbers: []int{1,?2,?3,?4,?5}, want: ? ?15, }, { name: ? ?"包含負數和零", numbers: []int{-1,?0,?10,?-5,?20}, want: ? ?30, }, { name: ? ?"所有數字均為負數或零", numbers: []int{-1,?-2,?0}, want: ? ?0, }, { name: ? ?"單個正數", numbers: []int{42}, want: ? ?42, }, }
// 2. 遍歷所有測試用例for?_, tt :=?range?tests {// 3. 使用 t.Run 創建子測試 t.Run(tt.name,?func(t *testing.T)?{ got := processNumbers(tt.numbers)
// 4. 驗證結果if?got != tt.want { t.Errorf("processNumbers() = %v, want %v", got, tt.want) } }) }}
模擬 (Mocking):
隔離"外部勢力",提升測試效率
單元測試的核心是“單元”,即隔離被測代碼。但現實中,許多代碼會依賴外部服務、數據庫、文件系統或其他復雜組件。直接在測試中使用這些真實依賴會讓測試變得緩慢、不穩定,甚至無法進行(比如待測試函數依賴于一個還沒上線的服務接口)。
此時,“模擬(Mocking)”就派上了用場。Mocking 就是用一個行為可控的“替身”來取代那些真實依賴,讓我們能專注于測試自己的代碼邏輯。
為什么要 Mock?
隔離性 (Isolation):確保測試只關注當前單元的行為,不受外部依賴變化的影響。
可控性 (Control):可以精確控制 Mock 對象的行為,比如讓它返回特定數據、模擬錯誤情況等。
速度 (Speed):Mock 對象通常比真實依賴快得多,能顯著提升測試執行效率。
確定性 (Determinism):避免真實依賴可能帶來的不確定性(如網絡波動、數據變化等)。
如何 Mock?
Go社區有多種流行的 Mock 框架,例如:GoMock、Testify/mock、monkey、gomonkey。
在團隊實踐中,gomonkey是常用的 Mock 框架之一,下面主要介紹一下gomonkey 的主要用法:
ApplyFunc - 替換函數:用于替換一個普通函數的實現。
patches := gomonkey.ApplyFunc(time.Now,?func()?time.Time {? ??return?time.Date(2023,?10,?1,?12,?0,?0,?0, time.UTC)})defer?patches.Reset()?// 測試結束后恢復原函數
ApplyMethod - 替換方法:用于替換一個結構體方法的實現。
patches := gomonkey.ApplyMethod(reflect.TypeOf(&someStruct{}),?"MethodName",? ??func(*someStruct, param1Type)?returnType {? ? ? ??// 模擬實現? ? ? ??return?mockValue? ? })defer?patches.Reset()
ApplyGlobalVar - 修改全局變量:允許在測試期間修改全局變量的值。
patches := gomonkey.ApplyGlobalVar(&somePackage.GlobalVar, newValue)defer?patches.Reset()?// 測試結束后恢復原值
ApplyPrivateMethod - 替換私有方法:通過反射機制替換私有方法。
patches := gomonkey.ApplyPrivateMethod(reflect.TypeOf(&someStruct{}),?"privateMethod",? ??func(*someStruct)?returnType {? ? ? ??return?mockValue? ? })defer?patches.Reset()
NewPatches - 批量創建補丁:創建一個補丁集合,用于管理多個 Mock。
patches := gomonkey.NewPatches()patches.ApplyFunc(func1, mockFunc1)patches.ApplyMethod(reflect.TypeOf(&obj{}), "Method", mockMethod)defer patches.Reset() // 一次性重置所有補丁
例如,我們項目中經常要感知各種外部配置(如 feature flag/A/B test),可以 Mock 這些配置的返回值,確保待測函數在各種配置下都能有預期的輸出。
假設我們有一個 processDataWithConfig 函數,它依賴一個外部 ConfigReader 服務來獲取配置,并根據配置決定是否處理數據:
package?main
import?("errors""fmt""reflect""testing""time"
"github.com/agiledragon/gomonkey/v2"?// 假設已經安裝"github.com/stretchr/testify/assert"?// 假設已經安裝)
// ConfigReader 是一個模擬外部配置服務的接口type?ConfigReader?interface?{ GetFeatureFlag(key?string)?bool GetThreshold(key?string) (int,?error)}
// DefaultConfigReader 是 ConfigReader 的默認實現,通常會從外部系統讀取type?DefaultConfigReader?struct{}
func?(d *DefaultConfigReader)?GetFeatureFlag(key?string)?bool?{// 實際代碼中會從配置中心讀取 fmt.Printf("DefaultConfigReader: Getting feature flag for %s\n", key)return?true?// 默認開啟}
func?(d *DefaultConfigReader)?GetThreshold(key?string) (int,?error) {// 實際代碼中會從配置中心讀取 fmt.Printf("DefaultConfigReader: Getting threshold for %s\n", key)if?key ==?"item_count"?{return?10,?nil?// 默認閾值 }return?0, errors.New("threshold not found")}
// processDataWithConfig 根據配置處理數據// configReader: 用于獲取配置的接口// data: 需要處理的整數數據func?processDataWithConfig(configReader ConfigReader, data?int)?(string,?error) {if?!configReader.GetFeatureFlag("enable_data_processing") {return?"", errors.New("data processing is disabled by feature flag") }threshold, err := configReader.GetThreshold("item_count")if?err !=?nil?{return?"", fmt.Errorf("failed to get threshold: %w", err) }
if?data > threshold { ? ? ? ?return?fmt.Sprintf("Data %d processed: Exceeds threshold %d", data, threshold),?nil }return?fmt.Sprintf("Data %d processed: Within threshold %d", data, threshold),?nil}
func?TestProcessDataWithConfig(t *testing.T)?{// 定義測試用例 testCases := []struct?{ name ? ? ? ??string setupMocks ??func(*gomonkey.Patches, *DefaultConfigReader)?// 修改為接收ConfigReader實例 inputData ? ?int wantResult ??string wantErr ? ? ?bool wantErrorMsg?string }{ { name:?"功能開關關閉時應返回錯誤", setupMocks:?func(patches *gomonkey.Patches, configReader *DefaultConfigReader)?{// 直接模擬具體類型的方法而不是接口 patches.ApplyMethod(configReader,?"GetFeatureFlag",func(_ *DefaultConfigReader, key?string)?bool?{ assert.Equal(t,?"enable_data_processing", key)return?false?// 模擬開關關閉 }) }, inputData: ? ?5, wantResult: ??"", wantErr: ? ? ?true, wantErrorMsg:?"data processing is disabled by feature flag", }, { name:?"獲取閾值失敗時應返回錯誤", setupMocks:?func(patches *gomonkey.Patches, configReader *DefaultConfigReader)?{// Mock GetFeatureFlag 為 true patches.ApplyMethod(configReader,?"GetFeatureFlag",func(_ *DefaultConfigReader, key?string)?bool?{return?true })// Mock GetThreshold 返回錯誤 patches.ApplyMethod(configReader,?"GetThreshold",func(_ *DefaultConfigReader, key?string)?(int,?error) { assert.Equal(t,?"item_count", key)return?0, errors.New("mock threshold error") }) }, inputData: ? ?5, wantResult: ??"", wantErr: ? ? ?true, wantErrorMsg:?"failed to get threshold: mock threshold error", }, { name:?"數據小于閾值時正常處理", setupMocks:?func(patches *gomonkey.Patches, configReader *DefaultConfigReader)?{ patches.ApplyMethod(configReader,?"GetFeatureFlag",func(_ *DefaultConfigReader, key?string)?bool?{?return?true?}) patches.ApplyMethod(configReader,?"GetThreshold",func(_ *DefaultConfigReader, key?string)?(int,?error) {?return?10,?nil?})?// 模擬閾值為10 }, inputData: ?5, wantResult:?"Data 5 processed: Within threshold 10", wantErr: ? ?false, }, { name:?"數據大于閾值時正常處理", setupMocks:?func(patches *gomonkey.Patches, configReader *DefaultConfigReader)?{ patches.ApplyMethod(configReader,?"GetFeatureFlag",func(_ *DefaultConfigReader, key?string)?bool?{?return?true?}) patches.ApplyMethod(configReader,?"GetThreshold",func(_ *DefaultConfigReader, key?string)?(int,?error) {?return?10,?nil?})?// 模擬閾值為10 }, inputData: ?15, wantResult:?"Data 15 processed: Exceeds threshold 10", wantErr: ? ?false, }, { name:?"時間相關的函數Mock示例", setupMocks:?func(patches *gomonkey.Patches, configReader *DefaultConfigReader)?{// Mock time.Now() 函數 patches.ApplyFunc(time.Now,?func()?time.Time {return?time.Date(2025, time.June,?22,?18,?30,?0,?0, time.UTC) })// 可以繼續設置其他 Mock patches.ApplyMethod(configReader,?"GetFeatureFlag",func(_ *DefaultConfigReader, key?string)?bool?{?return?true?}) patches.ApplyMethod(configReader,?"GetThreshold",func(_ *DefaultConfigReader, key?string)?(int,?error) {?return?5,?nil?}) }, inputData: ?3, wantResult:?"Data 3 processed: Within threshold 5", wantErr: ? ?false, }, }
for?_, tc :=?range?testCases { t.Run(tc.name,?func(t *testing.T)?{ patches := gomonkey.NewPatches()defer?patches.Reset()?// 確保每個測試用例結束后都重置補丁
// 創建一個默認的 ConfigReader 實例 configReaderInstance := &DefaultConfigReader{}
// 設置當前測試用例所需的 Mockif?tc.setupMocks !=?nil?{ tc.setupMocks(patches, configReaderInstance) }gotResult, err := processDataWithConfig(configReaderInstance, tc.inputData)
if?tc.wantErr { assert.Error(t, err)if?tc.wantErrorMsg !=?""?{ assert.Contains(t, err.Error(), tc.wantErrorMsg) } }?else?{ assert.NoError(t, err) assert.Equal(t, tc.wantResult, gotResult) } }) }}
gomonkey 的使用建議
謹慎使用,明確目的:只在真正需要的地方(如Mock時間、隨機數、第三方庫、模擬錯誤等)使用 gomonkey。
優先使用依賴注入和接口:雖然 gomonkey 功能強大,但最佳實踐仍然是通過依賴注入和接口來設計可測試的代碼。gomonkey 應作為最后的選擇。
避免過度 Mock:不要嘗試 Mock 所有的函數或方法。過度 Mock 會使測試變得脆弱,且與實際代碼行為相去甚遠。
確保清理 Mock:始終使用 defer patches.Reset() 來確保測試結束后恢復原始函數行為,避免影響其他測試。
編寫可讀的測試:為每個 Mock 添加注釋,解釋為什么需要它,并保持 Mock 函數的邏輯簡單明了。
使用 gomonkey 的注意事項
編譯限制:gomonkey 通過修改內存中的機器碼實現函數替換,這要求禁用Go編譯器的內聯優化。因此,測試需要添加編譯標志:go test -gcflags=all=-l。
線程安全問題:當在并發測試 (t.Parallel()) 中使用 gomonkey 時,需要特別小心,因為全局函數的替換會影響所有 goroutine。
不要在生產代碼中使用:gomonkey 僅為測試環境設計,絕對不能在生產代碼中使用它。
斷言
使用原生 testing 包進行斷言
在僅使用原生 testing 包的情況下,斷言通常是通過 if 語句和 t.Errorf 或 t.Fatalf 來實現的。
t.Logf(format, args...): 記錄日志信息,但測試繼續。
t.Errorf(format, args...): 報告一個錯誤,但測試會繼續執行。
t.Fatalf(format, args...): 報告一個致命錯誤,并立即停止當前測試函數的執行。
示例代碼:
假設我們有一個 Add 函數:
func?Add(a, b?int)?int?{? ??return?a + b}
對應的測試代碼可以這樣寫:
package?main
import?"testing"
func?TestAdd(t *testing.T)?{? ? result := Add(2,?3)? ? expected :=?5if?result != expected {? ? ? ??// 如果斷言失敗,打印錯誤信息并標記測試為失敗? ? ? ? t.Errorf("Add(2, 3) = %d; want %d", result, expected)? ? }}
這種方式的好處是非常清晰直接,沒有任何外部依賴。對于簡單的測試,這種方式完全足夠了。
借助第三方斷言庫
雖然原生方式很直接,但在有大量斷言邏輯的復雜測試中,代碼會顯得冗長和重復。社區有很多開源的斷言庫,使用最多的是testify/assert
assert提供了 Equal、NotEqual、Nil、NotNil、Len、Contains 等海量斷言函數,具體使用各位架構師可以翻閱文檔https://pkg.go.dev/github.com/stretchr/testify/assert
示例代碼 (使用 assert):
package?main
import?(? ??"testing"? ??"github.com/stretchr/testify/assert")
func?TestAddWithAssert(t *testing.T)?{? ??// assert.Equal 校驗兩個值是否相等? ??assert.Equal(t,?5, Add(2,?3),?"2+3 應該等于 5")?// 最后一個參數是可選的失敗信息? ??// assert.NotEqual 校驗兩個值是否不相等assert.NotEqual(t,?6, Add(2,?3),?"2+3 不應該等于 6")}
提升代碼可測試性
單一職責原則 (SRP):函數功能聚焦
核心理念:一個函數(或類、模塊)應該只有一個引起它變化的原因,即它只負責一項明確定義的任務。 一個很復雜的業務場景理想狀態應該是這樣的,而不是非常難讀懂難維護的又臭又長的函數:
有一個主流程函數作為協調者:它的職責是 “編排”和“決策”。它調用其他函數,處理它們之間的依賴關系和執行順序,并根據結果進行邏輯判斷。它不關心飛機發動機怎么造起來(底層細節),只關心造飛機發動機這一步是否成功以及如何把飛機發動機裝到飛機上(協調和流程)。
有多個職責函數作為執行者:它的職責就是“做好一件事”。目標就是功能純粹、容易預測、容易測試、容易發現問題。
對可測試性的影響:
減少測試用例的復雜性:當函數只做一件事時,其輸入、輸出和預期行為更容易定義和驗證。
減少依賴項數量:專注于單一職責的函數通常具有較少的外部依賴。
提高測試的精確性:測試失敗時,問題能被更準確地定位到具體功能點。
項目案例對比:
??好例子:獨立的驗證函數
// checkPositiveNumber 驗證數字是否為正數func?checkPositiveNumber(num?int)?error?{? ??if?num <=?0?{? ? ? ??return?errors.New("number must be positive")? ? }? ??return?nil}
// checkStringNotEmpty 驗證字符串是否為空func?checkStringNotEmpty(s?string)?error?{? ??if?s ==?""?{? ? ? ??return?errors.New("string cannot be empty")? ? }? ??return?nil}
??不好的例子:processInput 方法包含多個責任
func?processInput(value?int, name?string)?error?{? ??// 檢查數值是否有效? ??if?value <=?0?{? ? ? ??return?errors.New("invalid value: must be positive")? ? }// 檢查名稱是否有效? ??if?name ==?""?{? ? ? ??return?errors.New("invalid name: cannot be empty")? ? }// 執行一些基于數值的復雜計算? ??if?value >?100?{? ? ? ? fmt.Println("Processing large value...")? ? ? ??// ... 更多復雜計算邏輯? ? }?else?{? ? ? ? fmt.Println("Processing small value...")? ? ? ??// ... 更多不同計算邏輯? ? }// 記錄處理日志到外部系統 (副作用)? ? log.Printf("Processed value: %d, name: %s", value, name)return?nil}
建議:將 processInput 拆分為多個單一職責的函數,每個函數負責一種檢查或計算邏輯,這樣可以針對每個條件單獨編寫測試用例。例如:validateValue、validateName、performComplexCalculation、logProcessing 等。
依賴注入 (DI):解耦依賴,提升靈活性
核心理念:將對象所依賴的其他對象(依賴項)的創建和管理責任從其內部轉移到外部。
常見DI方式:
構造函數注入:依賴項通過構造函數傳入,存儲為結構體字段。
函數參數注入:依賴項直接作為函數參數傳入。
接口注入:通過接口定義依賴,實現松耦合。
對可測試性的影響:
允許測試替身:可以輕松傳入 Mock 對象替代真實依賴。
行為獨立性:函數行為不再依賴全局狀態或硬編碼的外部服務。
項目案例對比:
??好例子:通過參數注入依賴
// Logger 是一個簡單的日志接口type?Logger?interface?{? ? Log(message?string)}
// ConsoleLogger 是 Logger 接口的一個實現type?ConsoleLogger?struct{}
func?(l *ConsoleLogger)?Log(message?string) {? ? fmt.Println("LOG:", message)}
// DataProcessor 結構體依賴 Logger 接口type?DataProcessor?struct?{? ? Logger Logger?// 通過構造函數注入 Logger}
// NewDataProcessor 創建一個 DataProcessor 實例func?NewDataProcessor(logger Logger)?*DataProcessor {? ??return?&DataProcessor{Logger: logger}}
// Process 依賴于 Logger 進行日志記錄func?(dp *DataProcessor)?Process(data?string)?string?{? ? processedData := strings.ToUpper(data)? ? dp.Logger.Log(fmt.Sprintf("Processed: %s -> %s", data, processedData))? ??return?processedData}
??不好的例子:直接在函數內部創建依賴
// 假設的不良實踐示例// ProcessWithoutDI 直接在函數內部創建日志器func?ProcessWithoutDI(data?string)?string?{? ??// 硬編碼創建依賴,難以在測試中替換? ? logger := &ConsoleLogger{}?// 直接創建具體實現? ? processedData := strings.ToUpper(data)? ? logger.Log(fmt.Sprintf("Processed: %s -> %s", data, processedData))? ??return?processedData}
避免隱式依賴和全局狀態
核心理念:函數的所有依賴項都應該是顯式的,通過參數傳入或作為結構體字段存在。
隱式依賴的危害:
行為不可預測:函數行為可能受外部不可見因素影響。
測試難以設置和復現:難以控制隱式依賴的狀態。
并發測試風險:多個測試修改同一全局狀態可能產生競態條件。
項目案例對比:
??好例子:通過參數傳遞依賴
// calculatePrice 僅依賴傳入參數,無外部隱式依賴func?calculatePrice(basePrice?float64, discountRate?float64, taxRate?float64)?float64?{? ? netPrice := basePrice * (1?- discountRate)? ? finalPrice := netPrice * (1?+ taxRate)? ??return?finalPrice}
??不好的例子:使用全局變量或硬編碼配置
// 假設的不良實踐示例var?globalDiscountRate =?0.1?// 全局變量,隱式依賴
func?calculateFinalPrice(basePrice?float64)?float64?{? ??// 直接使用全局變量,測試時難以控制其值? ? taxRate :=?0.05?// 硬編碼常量? ? netPrice := basePrice * (1?- globalDiscountRate)? ? finalPrice := netPrice * (1?+ taxRate)? ??return?finalPrice}
這對測試有什么影響呢?假如case1測試globalDiscountRate = 0.1時函數的流轉情況,而case2 測試分支中又會修改globalDiscountRate值。 那么問題來了:假如case2先執行了,那么case1 就會不能通過,假如 case2 后執行,case1 就能通過。假如你正好開了并行測試(t.Parallel),此時你會得到一個薛定諤的測試,時而能通過又時而不能通過。
追求純函數:消除副作用,簡化測試
核心理念:純函數就像一個數學公式,輸出完全由輸入決定,給它相同的輸入,它永遠返回相同的輸出,且沒有可觀察的副作用。
純函數的特點:
確定性:相同輸入總是產生相同輸出。
無副作用:函數在執行過程中,不會對其作用域之外的任何狀態進行修改或交互。它不會改變世界,只是根據輸入計算出一個結果。
對可測試性的影響:
最易測試:只需驗證輸入與輸出的關系。
無需 Mock:不需要模擬外部依賴。
項目案例對比:
??好例子:純計算函數
// calculateDiscountedPrice 是一個純函數:只依賴輸入參數,無副作用// 計算商品的折后價格func?calculateDiscountedPrice(price?float64, discountPercentage?float64)?float64?{? ??if?discountPercentage <?0?|| discountPercentage >?100?{? ? ? ??return?price?// 無效折扣,返回原價? ? }? ??return?price * (1?- discountPercentage/100)}
??不好的例子:包含副作用的函數,通過指針參數修改外部狀態
type?Order?struct?{? ? ID ? ??string? ? Status?string? ? Items ?[]string? ? Total ?float64}
// finalizeOrder 包含副作用,會修改傳入的 Order 對象狀態func?finalizeOrder(order *Order, paymentStatus?string)?{? ??if?paymentStatus ==?"paid"?{? ? ? ? order.Status =?"completed"? ? ? ??// 假設這里會觸發一個外部系統調用,例如發送郵件或更新數據庫? ? ? ? fmt.Println("Order", order.ID,?"marked as completed. Sending confirmation email...")? ? ? ??// ... (實際的外部系統調用)? ? }?else?{? ? ? ? order.Status =?"pending"? ? ? ? fmt.Println("Order", order.ID,?"status set to pending.")? ? }? ??// 改變 Order 對象的 Total (副作用)? ? order.Total = order.Total *?0.9?// 假設有隱藏的內部折扣}
表1: 難以測試的代碼特征 vs. 易于測試的代碼特征
利用LLM提升單測效率
大型語言模型(LLM)可以成為我們編寫單元測試的強大助手,幫助我們快速生成樣板代碼和測試用例。
建議選擇的模型: Gemini, Claude
合適的工具: Cursor、Windsurf、Copilot、Trae等等,其他深度集成代碼庫的AI編輯器,個人覺得體感最好的還是Cursor
為什么要用 AI 編輯器?
Cursor可以提供深度的代碼庫上下文感知能力,一個普通的AI聊天插件,你問它問題時,它并不知道你正在看哪個文件,你的項目結構是怎樣的。而使用Cursor會在提問或請求修改的瞬間,智能地抓取并提供相關的上下文信息給 LLM。(具體原理是 Cursor會在后臺對整個項目文件進行索和“向量化”,當你輸入一個問題,問題本身也會embedding 成一個向量,并根據向量的遠近 找到相關的代碼片段,然后把這些代碼片段和問題一起傳給LLM )。
Cursor集成了Agent模式,能夠直接根據你的問題使用各種工具去完成任務,例如讓Cursor寫一個單測,它可以直接去調用命令行工具測試單測的正確性并修改代碼,無需你自己去寫單測,然后去運行測試、修改代碼。
“詠唱”技巧:
如何向LLM提問才能得到滿意的答復
與LLM有效溝通的關鍵在于“提示工程”(Prompt Engineering)。一個優秀的Prompt應具備以下特質。
清晰具體,直奔主題
明確你要測什么:是哪個函數?這個函數的哪個具體行為?期望它在什么輸入下產生什么輸出?同時,指定測試類型和風格。
反例:“測試一下 ProcessOrder 函數。”
正例:“請為 Go 函數 ProcessOrder(order Order) error 生成表驅動單元測試。測試用例應包括:1. 有效訂單成功處理;2. 訂單金額為零時返回錯誤;3. 庫存不足時返回特定錯誤。請使用 testify/assert 進行斷言。”
提供充足的“上下文”
AI的輸出質量直接取決于你提供的信息質量。上下文越豐富,AI的決策(生成的代碼)就越準確。
提供代碼:把你要測試的Go函數以及相關的結構體定義直接貼給AI。在Cursor這類工具中,可以直接@引用代碼文件或片段。
解釋代碼意圖:用注釋或自然語言解釋函數的業務邏輯、參數含義、返回值和潛在的副作用。AI能讀懂代碼的“模式”,但無法理解代碼背后的“業務邏輯”。
展示“優秀范例”:如果你項目中有寫得好的、符合團隊風格的測試用例,可以作為例子展示給AI,讓它“學習”并模仿你的風格。
為什么上下文很重要呢?就像在英雄聯盟中玩打野,只有獲取到足夠的全局信息才能做出更好的下一步決策(是抓下路,還是刷野,還是控資源),AI 也一樣,上下文越豐富,做出的決策也就更加正確。
迭代優化,循循善誘
不要指望一步到位。AI的第一次輸出可能不完美,這很正常。把它當作一個需要指導的初級程序員。
逐步求精:如果結果不滿意,嘗試換種問法,補充更多信息,或者直接指出它的錯誤讓它修改。
例如,如果生成的測試用例不夠全面,你可以說:“很好,請再補充一些針對輸入字符串為空或包含特殊字符的測試用例。”
請求解釋:如果AI生成了看不懂的“騷操作”,大膽地問它。
例如:“你能解釋一下為什么這里要用gomonkey來mock time.Now嗎?”
設定角色
“你現在是一名資深 Golang 開發工程師,并且是單測方面的專家。接下來請你幫我分析一下我項目中 xxx 函數功能以及外部依賴,并提出單測 case建議。”
“你是一名擅長用清晰易懂的方式解釋復雜概念的布道師。請幫我解釋一下我選中的這段代碼,它實現了什么功能,以及這樣寫的好處是什么?”
總結與展望
先寫代碼,還是先“聊”測試?
傳統的開發流程通常是先寫功能代碼,然后再補單元測試。但測試驅動開發 (TDD) 和行為驅動開發 (BDD) 則倡導“測試先行”。大模型能在中間為我們做什么呢?
傳統流程 + AI
在傳統的開發流程中,開發者首先完成功能代碼的編寫。隨后,將函數代碼交給大模型,請求其生成單元測試。例如,可以向大模型提出這樣的需求:“幫忙為這個函數用表驅動風格生成單元測試,覆蓋以下這些場景……同時,需要對某些外部依賴進行 Mock。”這種利用大模型輔助生成單元測試的方式,是目前較為常見的實踐。
TDD/BDD + AI (AI 輔助“測試先行”)
第一步:定義“契約”。直接寫一個函數簽名,或者接口定義,然后向 大模型描述一個你將要實現的功能。
第二步:讓 AI“出題”。請求大模型:“基于這個描述/簽名,請幫我生成一些符合 TDD 原則的單元測試用例。這些測試現在應該是失敗的。” 大模型可能會根據你的描述,嘗試生成一些針對預期行為的測試框架。
第三步:你來“解題”。拿到 AI 生成的(或者說“建議的”)測試框架后,你再去編寫實際的 Go 功能代碼,目標就是讓這些測試全部通過。
第四步:重構與完善。代碼能跑通測試后,再進行重構,并可以再次借助 AI 檢查是否有遺漏的測試場景。
這種 AI 輔助的 TDD/BDD 流程,AI 扮演了一個“需求分析師”和“測試用例生成初稿員”的角色。提供一個思考的起點,然后由各位架構師們去完成實驗。
大模型生成為主,人工優化為輔
大模型輔助編程的核心原則:把大模型當作一個能力超群但經驗不足的助手,而不是可以完全放權的總工程師。
大模型的強項:
快速生成代碼骨架和樣板代碼(比如表驅動測試的架子)。
處理重復性、模式化的任務(比如為多種相似輸入生成用例)。
提供多樣化的測試思路(比如它可能會想到一些你沒注意到的邊界值)。
各位架構師們的核心價值:
需求理解與邏輯設計:深刻理解業務需求,設計合理的測試策略。
批判性思維與質量把控:嚴格審查 AI 生成的代碼,確保其正確性、健壯性和可維護性 。
復雜問題解決:處理 AI 難以理解的復雜邏輯、依賴關系和微妙的業務規則。
代碼“品味”與風格統一:確保測試代碼符合項目規范和 Go 語言的最佳實踐,讓代碼庫保持優雅。
確保測試的“靈魂”:單元測試不僅僅是為了追求覆蓋率完成?CI/CD流水線,更重要的是它能準確反映代碼的預期行為,并作為一種“活文檔”存在。這種“靈魂”是 AI 目前難以賦予的。
所以,理想的工作流是:讓 AI 完成 60%-80% 的“體力活”,然后你再花 20%-40% 的精力去“畫龍點睛”,進行優化、修正和完善。
維護一個高質量的“提示詞庫”
例如很多社區網站都維護了針對各種場景的提示詞庫,是不是也能針對我們團隊項目常見的測試/開發模式維護一個提示詞庫呢?
提示詞庫:https://github.com/holmquistc407/ai-tishici
對于項目中常見的測試/開發模式,維護一套通用的提示詞比如:
測試一個與實驗平臺/配置中心/灰度開關交互的函數(需要 Mock 對應接口)。
測試一個過濾 ProductLineList 中數據的函數,測試一個在 xx 條件下會默勾 xx 車型的函數。
不僅是測試
“幫我開發一個會與實驗平臺交互的函數,函數將使用xx 實驗因子讀取實驗,實驗策略樣例如...,讀取實驗策略后將會用策略中 xx 參數,計算車型的打分,并根據車型的打分默勾 xx 車型。”
“幫我開發一個與 灰度開關交互的函數,函數將會讀取使用xxx開關接入名讀取開關,讀取開關后,如果開關關閉就返回,如果打開就 xxxx。”
如果團隊能夠沉淀下來一套針對這些場景的、經過驗證的、高效的“AI 提示詞模板”,是不是能夠提升效率和一致性呢?
“提示詞庫”的好處:
經驗共享:把個人摸索出來的有效 Prompt 變成團隊財富。
效率提升:新人也能快速上手,直接套用模板。
質量保證:模板化的 Prompt 通常能引導 AI 生成更符合期望的測試代碼。
持續優化:這個庫可以像代碼一樣被版本控制、評審和持續改進。
大模型輔助:機遇與挑戰并存
大模型確實可以顯著提升生產力/幸福度,有效減少重復性比較高比較枯燥的工作,把更多寶貴的時間和腦細胞,投入到思考更復雜的業務邏輯。它還能提供多樣化的測試思路,幫助開發者發現更多潛在問題。
然而,LLM也存在一些潛在風險:
測試方案理解不足:如果對 LLM 生成的測試方案或 Mock 技巧不理解,盲目使用可能會導致錯誤。
掩蓋代碼問題:即使代碼本身有錯誤,LLM 生成的測試也可能通過,從而掩蓋潛在問題。
“幻覺”問題:LLM 可能生成看似合理但完全錯誤的代碼,如果未被識別,會導致錯誤的測試結果。
代碼質量參差不齊:生成的測試代碼可能邏輯不優、結構混亂,甚至不符合語言習慣,增加后續維護成本。
如果不對這些“半成品”進行打磨,直接入庫,那么未來維護這些測試函數的成本可能會非常高,甚至超過了大模型為你省下來的時間。
有獎互動
AI并非萬能的“銀彈”,而是“瑞士軍刀”中的一把新工具。在AI時代,人類的智慧、經驗和批判性思維不僅沒有過時,反而愈發重要。歡迎分享你的經驗與思考,一起探討!小編將抽取2位,送上滴滴技術周邊短袖T恤。