单元测试的必要性与基础
单元测试不仅是保障代码质量的手段,也是优秀的设计工具和文档形式,对软件开发具有重要意义。
另一种形式的文档:好的单元测试是一种活文档,能清晰展示代码单元的预期用途和行为,有时比注释更有用。
提升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 := 5
if 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恤。