背景
什么是单测
单元测试是一项由开发人员或测试人员对程序模块的正确性进行检验测试的工作,用于检查被测试代码的功能是否正确。单元测试是软件工程中降低开发成本,提高软件质量常用方式之一。
正确性:同一单元,在给定的输入下,总能得到预期的输出。
为什么要写单测
- 在业务快速迭代过程中,单元测试能够保证业务逻辑的一致性。(如修改业务逻辑、或者引入缺陷导致业务逻辑变更,单测会发现问题并报错,前提是测试用例能够覆盖到改动的场景)
- 利于未来的代码重构,保证基本的业务功能不变,不会出现严重的基本功能不可用的情况。
- 复杂的代码块单测十分复杂,有助于开发者将复杂逻辑拆分为多个内聚的小模块,降低了代码耦合度,提升了代码质量,内聚的小模块也更方便测试。
- 整体单测覆盖率达到一定程度后,能够保证项目的基本质量。
- 更有信心修改代码,自动化的单测能够确保基本业务逻辑没有问题,还能节省开发者自测的时间。
- 推动测试驱动开发(TDD,Test-Driven Development),让开发者在开发前优先设计接口,考虑各种输入输出场景,提升代码质量,覆盖更多边界用例。
……
为什么都不想写单测
- 需要花费更多时间写单测,修改一行代码,要写300行单测。(从👆🏻能看出来,在未来可以节省很多时间,还能提升质量,减少故障)
- 代码太复杂,不够内聚,一个方法几百行,依赖了各种模块,调用满天飞。(需要重构代码,拆分为内聚的模块,不仅方便写单测,还能提升代码质量,方便未来迭代)
- 写单测很复杂,不清楚怎么写好单测。(模块设计合理的前提下,大部分单测都不复杂,👇🏻也会有一些最佳实践)
什么是好的单测实践
- 简短、粒度小:只有一个测试目的,不应该包含过多计算逻辑,尽量只有输入、输出和断言。
- 独立性:保证单元测试稳定可靠且便于维护,单测用例之间不能互相调用,也不能依赖执行的先后次序。
- 可重复:单元测试通常会被放到持续集成中,每次有集成时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
- 自动化:单元测试应该是全自动执行的,并且非交互式的。
实践
Go Mockito
使用字节开源的 mockey 库可以很方便的实现函数 mock,解耦依赖调用,专注测试方法内部的业务逻辑。
Mockey 是一款简单易用的 Golang 打桩工具库,能够快速方便地进行函数、变量的 mock,目前在字节跳动各业务的单元测试编写中应用较为广泛,其底层是通过**运行时改写函数指令**实现的猴子补丁(Monkey Patch)。
// 示例代码
package main
import (
"syscall"
"unsafe"
)
func a() int { return 1 }
func b() int { return 2 }
func rawMemoryAccess(b uintptr) []byte {
return (*(*[0xFF]byte)(unsafe.Pointer(b)))[:]
}
func assembleJump(f func() int) []byte {
funcVal := *(*uintptr)(unsafe.Pointer(&f))
return []byte{
0x48, 0xC7, 0xC2,
byte(funcVal >> 0),
byte(funcVal >> 8),
byte(funcVal >> 16),
byte(funcVal >> 24), // MOV rdx, funcVal
0xFF, 0x22, // JMP [rdx]
}
}
func replace(orig, replacement func() int) {
bytes := assembleJump(replacement)
functionLocation := **(**uintptr)(unsafe.Pointer(&orig))
window := rawMemoryAccess(functionLocation)
copy(window, bytes)
}
func main() {
replace(a, b)
print(a())
}
严格来说 Monkey Patch 是针对动态语言的,Go、Java 这类静态语言是利用了语言特性实现了运行时的行为变更,相关讨论:
示例
编译时需要禁⽤内联和编译优化,否则可能会mock失败或者报错:-gcflags=“all=-l-N”
func SetProtectInstances(ctx context.Context, acccountId, groupId string, instanceIds []string) error {
//...
groupModel, err := groupDao.GetGroupById(ctx, groupId)
//...
instanceChargeTypeMap, err := listInstanceChargeTypeMap(accountId, instanceIds)
//...
}
函数内调用了外部函数,外部函数内又可能会有其他外部调用,从单元测试的原子性来说,这些外部调用是需要被 Mock 的。通过控制这些函数的数据,来覆盖被测方法的多个分支流,实现多个场景的用例覆盖。
普通函数 Mock
listInstanceChargeTypeMap
查询了实例的付费类型,不属于 SetProtectInstances
方法单测的内容,需要 Mock 返回数据。普通函数 Mock:
mockito.Mock(listInstanceChargeTypeMap).Return(targetMap, nil).Build()
其中listInstanceChargeTypeMap
函数签名:
func listInstanceChargeTypeMap(accountId string, instanceIds []string) (map[string]string, error)
Mock 接口传入函数名,Return 接口支持传入多个参数,需要和被 Mock 函数返回参数个数和类型保持一致,最后调用 Build 函数,完成函数 Mock。
也可以通过 When 控制 Mock 条件:
mockito.Mock(listInstanceChargeTypeMap).When(func(accountId string, instanceIds []string) bool {
return accountId == "1"
}).Return(tt.mockEcsChargeTypeMap, nil).Build()
// 对于成员函数
func (c *Class) listInstanceChargeTypeMap(accountId string, instanceIds []string) (map[string]string, error)
mockito.Mock((*Class).listInstanceChargeTypeMap).When(func(self *Class, accountId string, instanceIds []string) bool {
return accountId == "1"
}).Return(tt.mockEcsChargeTypeMap, nil).Build()
成员函数 Mock
公开结构体
对于被测方法内调用的公开 struct 的方法,Mock 方法:
Mock((*Class).FunA).Return(xxx).Build()
私有结构体
SetProtectInstances
中还调用了 DAO 层的一些函数,由于方法所属结构体是私有的,无法通过上面这种方法 Mock,需要使用GetPrivateMethod
通过反射拿到方法的指针:
mockito.Mock(mockito.GetPrivateMethod(groupDao,
"GetGroupById")).Return(tt.mockGroup, nil).Build()
示例单测
func TestSetProtectInstances(t *testing.T) {
testCases := []struct {
name string
accountId string
groupId string
instanceIds []string
expectErr error
mockGroup *models.Group
mockInstanceChargeTypeMap map[string]string
}{
{
name: "demo test case",
accountId: "123123",
instanceIds: []string{"i-123123"},
expectErr: nil,
mockGroup: &models.Group{},
mockInstanceChargeTypeMap: map[string]string{},
},
}
// testcases 保存不同场景的测试用例
for _, tt := range testCases {
mockito.PatchConvey(tt.name, t, func() {
mockito.Mock(mockito.GetPrivateMethod(groupDao,
"GetGroupById")).Return(tt.mockGroup, nil).Build()
mockito.Mock(listInstanceChargeTypeMap).When(func(accountId string, instanceIds []string) bool {
return accountId == "1"
}).Return(tt.mockInstanceChargeTypeMap, nil).Build()
res, err := SetProtectInstances(ctx, tt.accountId, tt.groupId, tt.instanceIds)
assert.Equal(t, tt.expectErr, err)
})
}
}
- 通过匿名结构体定义testcase,使⽤testcase数组保存不同场景的⽤例。
- 正常Mock后需要⼿动调⽤Mock接⼝的UnPatch⽅法取消mock代理,可以使⽤PatchConvey ⾃动释放当前convey内部的patch,免去 defer 的苦恼。
- 拿到结果要写断言,确保和期望结果一致。
总结
对于业务代码,写单测的难点是各个函数的Mock如何实现,以及控制流(如并发场景)如何测试。 基本思路是把控制和业务逻辑分离,尽量⽤简单的代码实现业务逻辑的单测,控制流通过专⻔的测试⽤例去覆盖,这种⽅法基本能覆盖绝⼤多数业务代码的单测需求。
更多 Mock 方法参考 https://github.com/bytedance/mockey