写出有意义并且健康的单测

293 阅读9分钟

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 针对某个功能,要写哪些单测

以脑图的形式画出某个功能的前置条件以及预期结果

例如写一个加法计算器的单测

image.png

  • 首先我会画上类似上面的脑图
  • 然后在评审时讨论这些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. 总结

  1. 有意义的单测:需要验证经过处理之后得到数据和状态是否符合预期,而不只是关注有无抛出错误
  2. 比较规范的单测:理解FIRST原则
  3. 健康、可维护的单测:理解各种测试用例的区别以及测试金字塔模型
  4. 全面、清晰的单测:结合脑图和GWT模板