1. 前言
最近团队OKR要求提高代码单测的覆盖率,但是看了大部分小伙伴的单测之后,有种为了应付任务而写单测的感觉,例如:
- 不关注实现细节,只关心函数返回的error是否为nil
- 不关注边界场景,函数的单测用例只有一两个
- 不关注存储、外部依赖,遇到组件调用统统mock返回
例如:
// 更新用户信息的单元测试
func TestUpdateUserInfo(t *testing.T) {
ctx := context.Background()
userInfo := GenerateUserInfo()
err := UpdateUserInfo(ctx, &UpdateuserInfo{
LoginUserID : 12345,
UserInfo : ...,
})
So(err, ShouldBeNil) // 只关心函数返回的error是否为nil
}
下面写写我对单测的一些理解,以及有哪些理论会对写单测有帮助的
对于单测的价值、讲解、教程,使用哪些组件/框架写,网上一搜一大堆帖子,书籍也有很多,我就不啰嗦了。
2. 要测哪些内容(写出有意义的单测)
个人觉得这个很重要,怎样才能通过单测保证业务逻辑的正常性呢(要测哪些内容)
下面代码重点看看注释做了哪些事情就好,只是随便举的例子,重点关注各种case的单测校验的内容即可
2.1 MySQL、Redis等的存储
- 新增、修改、删除操作。 把对应的数据行取出校验字段是否符合预期
func TestCreateUser() {
// Given
name, age := "XiaoMing", 25 // 将要输入的姓名以及年龄
ctx, userInfo := context.Background(), GenerateUserInfo(name, age)
defer DeleteUser()
// When
userID, err := CreateUser(ctx, userInfo) // 需要测试的逻辑
// Then
So(err, ShouldNotBeNil)
userInfo, errGetUser := userRepo.GetUserByID(ctx, userID) // 取出userInfo
So(errGetUser, shouldBeNil)
So(userInfo.Name, ShouldEqual, name) // 断言姓名是否符合预期
So(userInfo.Age, ShouldEqual, age) // 断言年龄是否符合预期
}
- 涉及事务操作。需要验证回滚后的数据是否符合预期
// 假设AddEmployee函数
// 1. 创建员工,
// 2. 建立员工和部门的关系
// (这两个操作在一个事物里)
func TestCreateUser() {
// Given
...
// When
// 假设部门是不存在的,那么这个‘员工和部门关联’的操作会失败
employeeID, err := AddEmployee(ctx, employeeInfo, deptInfo)
// Then
// 预期是创建员工的操作会回滚
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, ErrXXXX.Eorror()) // 创建失败了
employee, errGetEmployee := employeeRepo.GetEmployeeByID(ctx, employeeID)
So(errGetEmployee, shouldBeNil)
So(employee, ShouldBeNil) // 判断是否成功回滚,数据库无该数据证明回滚成功
}
2.2 外部RPC/HTTP调用
- 校验RPC/HTTP入参是否满足预期
- 校验调用次数是否满足预期(看情况,比如有重试场景)
func TestCreateUser() {
// Given
name, age := "XiaoMing", 25
ctx, userInfo := context.Background(), GenerateUserInfo(name, age)
defer DeleteUser()
createUserMock :=
Mock(rpc.CreateUser).To(func(ctx context.Context, user *User) (userID int64, err error){
// 断言调用RPC的入参是否满足要求
So(user ShouldBeNil)
So(user.Name, ShouldEqual, name)
So(user.Age, ShouldEqual, age)
return user.ID, nil
}).Build()
// When
err := XXXX(ctx, userInfo)
// Then
So(err, ShouldBeNil)
So(createUserMock.Times(), ShouldEqual, 1) // 断言RPC的调用次数
}
2.3 消息队列
- 生产者
- 跟调用RPC/HTTP一样,需要关注调用参数以及调用次数
- 消费者
- 当作平常的函数/方法,测试接收到消息后的逻辑
- 也需要额外关注一下重复消费的问题
2.4 函数/方法的返回值
func TestGetUser() {
// Given
ctx, name := context.Background(), "XiaoMing"
// When
userInfoList, err := GetUsersByName(ctx, name)
// Then
So(err, ShouldBeNil)
So(len(userInfoList), ShouldEqual, expectNum) // 断言用户数量是否符合一致
for _, userInfo := range userInfoList {
So(userInfo.Name, ShouldEqual, name) // 断言姓名是否符合预期
}
}
还有并发场景、定时任务等场景,就不一一列举了
总结来说就是,验证经过处理之后得到数据和状态是否符合预期
3. 能帮助写单测的一些理解(写出较为规范、健康、可维护的单测)
3.1 FIRST原则
3.1.1 Fast
每个单测都应该很快就被执行完(毫秒级别),原因如下
- 不依赖外部资源。调用外部资源或者获取真实数据的操作应该都会被mock
- 简单。模块/功能应该被拆分得足够简单、足够单一,足够细,不会干太复杂的事
- 功能单一。每个单测只会测一个功能里面的具体某一场景(具体看4.2的例子)
3.1.2 Independent / Isolated
单测之间应该是彼此独立的。(单测的执行不依赖其执行顺序)
- 避免数据共享。每个单测的数据要保持独立。假如共用了数据,那么在单测结束时需要将数据还原
// BadCase在测试创建用户逻辑之后,没有清除对应的用户数据
// 1. 获取用户逻辑的单测 依赖 创建用户的单测
// 2. 不可重复,假如name有唯一索引,那么第二次执行创建用户的单测,会因为第一次创建成功而失败
func BadCase_TestCreateUser() {
// Given
name, age := "XiaoMing", 25
ctx, userInfo := context.Background(), GenerateUserInfo(name, age)
// When
userID, err := CreateUser(ctx, userInfo)
// Then
...
}
func BadCase_TestGetUser() {
// Given
ctx, name := context.Background(), "XiaoMing"
// When
userInfoList, err := GetUsersByName(ctx, name)
// Then
...
}
----------------------------------------------------------------------------------
func GoodCase_TestCreateUser() {
// Given
name, age := "XiaoMing", 25
ctx, userInfo := context.Background(), GenerateUserInfo(name, age)
defer DeleteUser() // 清除这个单测造成的数据影响
// When
userID, err := CreateUser(ctx, userInfo)
// Then
...
}
func BadCase_TestGetUser() {
// Given
ctx, name := context.Background(), "XiaoMing"
CreateUser(...) // 创建这个单测所需要的数据
defer DeleteUser() // 清除数据,避免对其他单测造成影响
// When
userInfoList, err := GetUsersByName(ctx, name)
// Then
...
}
- 重置mock。我使用的框架,重置mock的方法可以使用PatchConvey包起mock的逻辑,或者使用Unpatch()方法
3.1.3 Repeatable
可重复执行。
- 无论何时何地,单测应该都能被正确执行
- 每次运行都能得到相同的结果
// 只是举个例子罢了
// time.Now()可以比作我们需要测试的函数
// 需要把里面的第三方依赖或者影响单测的因素给Mock掉
// 从而得到一个可重复执行的单测
func BadCase() {
startTime := time.Now()
So(startTime.Format("2006-01-02"), ShouldEqual, "2023-01-04")
}
func GoodCase() {
Mock(time.Now).Return(time.Unix(1671381384, 0)).Build() // 2022-01-04的时间戳
startTime := time.Now()
So(startTime.Format("2006-01-02"), ShouldEqual, "2023-01-04")
}
3.1.4 Self-validating
单测执行完成,就可以知道单测的执行结果,无需人工介入。
所以这时候需要用到一些断言工具来帮助校验单测是否符合预期
func BadCase() {
startTime := time.Now()
fmt.Println(startTime.Format("2006-01-02")) // 通过人工观测打印的日期是否正确
}
func GoodCase() {
Mock(time.Now).Return(time.Unix(1671381384, 0)).Build()
startTime := time.Now()
So(startTime.Format("2006-01-02"), ShouldEqual, "2023-01-04") // 通过断言工具进行自动校验
}
3.1.5 Timely && Thorough
及时并且全面。
-
及时补上单测。能这期需求写完的单测,就不要拖到下期了,按照经验,一般说下期补上的大概率都不会补。
-
如何写出比较全面的单测呢。(下面只是个人比较习惯,有更好的做法欢迎讨论)
- 在进行技术设计的阶段,把需要测试的case,以脑图的形式画出前置条件以及预期结果
- 然后在技术评审阶段,跟其他开发和测试同事一起讨论case是否比较全面
- 最后根据这些脑图去编写单测
-
一般来说,能以TDD的模式去编写单测然后再重构开发是及时最全面的了,但是我很少使用TDD,原因如下。不是说TDD不好,只是通常来说比较难落地,当然有这个TDD的意识我觉得也挺好的
- 时间:TDD应该占项目多少时间才合适呢,而且一般在老板们的眼里,需求那是越快完成越好,能不能要到这部分时间还是个问题。
- 价值:怎样能体现出TDD在项目中带来的收益呢,多长的时间才能体现出价值呢。
- 共识:怎样跟团队的人达成使用TDD的共识
3.2 单元测试、集成测试、系统测试的区别
大学时候的笔记,忘记从哪里摘抄的了,贴一下
| 类型 | 测试对象 | 测试范围、内容 | 黑盒/白盒 | 粒度 |
|---|---|---|---|---|
| 单元测试 | 工作单元 (单元大小自己定义) | 面向对象编程时,一般是非private的方法 函数式编程时,一般是一个函数 使用Mock隔离依赖 | 白盒 | 细粒度 |
| 集成测试 | 内部功能,多个工作单元 | 多个类之间或者与外部系统的交互 使用真实依赖 | 白盒 | 中粒度 |
| 系统测试 | 外部功能,多个内部功能 | 验证需求中每一项功能的正确性 。绕过界面,系统测试针对应用层的每一个方法 | 黑盒 | 粗粒度 |
| 验收测试 | UI界面 | 直接通过页面对系统进行测试 | 黑盒 | 粗粒度 |
3.3 金字塔结构
- 编写不同粒度的测试
- 层次越高,编写的测试越少
为了维持金字塔形状,一个健康、快速、可维护的测试组合应该是这样的:写许多小而快的单元测试
4. 写单测的一些小套路(写出更全面、更清晰的单测)
4.1 针对某个功能,要写哪些单测
以脑图的形式画出某个功能的前置条件以及预期结果
例如写一个加法计算器的单测
- 首先我会画上类似上面的脑图
- 然后在评审时讨论这些case是否足够全面
- 在写单测时,这个计算器的逻辑就会写出相应的六个单测
4.2 单测的模板
Given-When-Then (GWT)是一个很好的单测模板
- Given:给出上下文,通常这个阶段是用来准备必要的参数,以及一些mock方法。这个是单测中相对来说最复杂的部分了。相对脑图来说,就是前置条件的部分。
- When:调用、触发需要测试的逻辑。相对脑图来说,就是加法计算器的逻辑
- Then:验证结果。相对脑图来说,就是最后的结果部分
// 加法计算器逻辑
var ErrOverflow = errors.New("溢出错误")
func AdditionCalculator(summand, addend int32) (result int32, err error) {
result = summand + addend
if summand > 0 && addend > 0 && result < summand {
return 0, ErrOverflow
}
if summand < 0 && addend < 0 && result > summand {
0, ErrOverflow
}
return
}
// 单测举例
func TestAdditionCalculator(t *testing.T) {
Convey("加法计算器单测", t, func(){
Convey("两个正数相加不溢出", twoPositiveNumberNotOverflow)
Convey("两个正数相加溢出", twoPositiveNumberOverflow)
Convey("两个负数相加不溢出", twoNegativeNumberNotOverflow)
.....
})
}
// 单测模板举例
func twoPositiveNumberNotOverflow() {
// Given
summand, addend := 1, 5 // A=1,B=5
// When
result, err := AdditionCalculator(summand, addend)
// Then
So(err, ShouldBeNil)
So(result, ShouldEqual, summand + addend) // 预期返回 A + B
}
func twoPositiveNumberOverflow() {
// Given
summand, addend := math.MaxInt32, 1 // A=2147483647,B=1
// When
result := AdditionCalculator(summand, addend)
// Then
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, ErrOverflow.Error()) // 预期返回溢出错误
}
...
5. 总结
- 有意义的单测:需要验证经过处理之后得到数据和状态是否符合预期,而不只是关注有无抛出错误
- 比较规范的单测:理解FIRST原则
- 健康、可维护的单测:理解各种测试用例的区别以及测试金字塔模型
- 全面、清晰的单测:结合脑图和GWT模板