单元测试分享Golang

2023年 8月 18日 40.7k 0

背景

单元测试,简称单测,是一种白盒测试,目的是在开发阶段测试一小段代码的正确性。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。单元测试主要是模块内程序的逻辑、功能、参数传递、变量引用、出错处理及需求和设计中具体要求方面的测试。

单元测试的好处:

  • **提高代码质量:**代码测试都是为了帮助开发人员发现问题从而解决问题,提高代码质量。

  • **尽早发现问题:**问题越早发现,解决的难度和成本就越低。

  • **保证重构正确性:**随着功能的增加,重构(修改老代码)几乎是无法避免的。很多时候我们不敢重构的原因,就是担心其它模块因为依赖它而不工作。有了单元测试,只要在改完代码后运行一下单测就知道改动对整个系统的影响了,从而可以让我们放心的重构代码。

  • **简化调试过程:**单元测试让我们可以轻松地知道是哪一部分代码出了问题。

  • **简化集成过程:**由于各个单元已经被测试,在集成过程中进行的后续测试会更加容易。

  • **优化代码设计:**编写测试用例会迫使开发人员仔细思考代码的设计和必须完成的工作,有利于开发人员加深对代码功能的理解,从而形成更合理的设计和结构。

  • **单元测试****是最好的文档:**单元测试覆盖了接口的所有使用方法,是最好的示例代码。而真正的文档包括注释很有可能和代码不同步,并且看不懂。

难点

  • **破除外部依赖:**单元测试一般不允许有任何外部依赖(文件依赖,网络依赖,数据库依赖等),我们不会在单元测试代码中去连接数据库,调用api等。这些外部依赖在执行测试的时候需要被模拟(mock/stub)。在测试的时候,我们使用模拟的对象来模拟真实依赖下的各种行为。

  • 掌握单元测试粒度:单元测试粒度是让人十分头疼的问题,特别是对于初尝单元测试的程序员。测试粒度做的太细,会耗费大量的开发以及维护时间,每改一个方法,都要改动其对应的测试方法。当发生代码重构的时候那简直就是噩梦(因为你所有的单元测试又都要写一遍了...)。 如单元测试粒度太粗,一个测试方法测试了n多方法,那么单元测试将显的非常臃肿,脱离了单元测试的本意,容易把单元测试写成集成测试。

成本&收益

  • 依赖很少的简单的代码(左下):对于外部依赖少,代码又简单的代码。自然其成本和价值都是比较低的。举Go官方库里errors包为例,没有任何外部依赖,代码也很简单,所以其单元测试起来也是相当方便。

  • 依赖较多但是很简单的代码(右下):依赖一多,mock和stub就必然增多,单元测试的成本也就随之增加。但代码又如此简单,这个时候写单元测试的成本已经大于其价值,还不如不写单元测试。

  • 依赖很少的复杂代码(左上):像这一类代码,是最有价值写单元测试的。比如一些独立的复杂算法(银行利息计算,保险费率计算,TCP协议解析等),像这一类代码外部依赖很少,但却很容易出错,如果没有单元测试,几乎不能保证代码质量。

  • **依赖很多又很复杂的代码(右上)**显然是单元测试的噩梦。写单元测试吧,代价高昂;不写单元测试吧,风险太高。像这种代码我们尽量在设计上将其分为两部分:1.处理复杂的逻辑部分 2.处理依赖部分 然后1部分进行单元测试

关键概念

Mock 和 Stub

Stub 和 mock 应该是最容易混淆的,而且习惯上我们统一用mock去形容模拟返回的能力,习惯成自然,也就把mock常挂在嘴边了。

Stub

Stub is an object that holds predefined data and uses it to answer calls during tests. It is used when we cannot or don’t want to involve objects that would answer with real data or have undesirable side effects.

Stub 代指那些包含了预定义好的数据并且在测试时返回给调用者的对象。Stub 常被用于我们不希望返回真实数据或者造成其他副作用的场景。

Stub 的典型应用场景即是当某个对象需要从数据库抓取数据时,我们并不需要真实地与数据库进行交互,而是直接返回预定义好的数据。

image

如上图所示,当我们要计算学生的平均成绩,需要读取数据库。使用Stub的方式可以免除与数据库交互的操作,从而可以简便的测试当前函数的执行逻辑是否符合预期即可。

Mock

Mocks are objects that register calls they receive. In test assertion we can verify on Mocks that all expected actions were performed.

Mocks 代指那些仅记录它们的调用信息的对象,在测试断言中我们需要验证 Mocks 被进行了符合期望的调用。

当我们并不希望真的调用生产环境下的代码或者在测试中难于验证真实代码执行效果的时候,我们会用 Mock 来替代那些真实的对象。典型的例子即是对邮件发送服务的测试,我们并不希望每次进行测试的时候都发送一封邮件,毕竟我们很难去验证邮件是否真的被发出了或者被接收了。我们更多地关注于邮件服务是否按照我们的预期在合适的业务流中被调用。

image

如上图所示,为了保障安全,我们需要做两件事,关门以及关窗。当前函数并不关心门是不是真的关好了,窗户是不是真的关好了,那是对应的函数 window*、 door * 函数需要负责的事情,而我们需要保障的只是当前函数 “securityOn( )” 是否真正的调用了关窗和关门的操作。

Mock和Stub的区别

在Go语言中,可以这样描述Mock和Stub:

  • Mock:在测试包中创建一个结构体,满足某个外部依赖的接口 interface{}

  • Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法

Fake

Fakes are objects that have working implementations, but not same as production one. Usually they take some shortcut and have simplified version of production code.

Fake 是那些包含了生产环境下具体实现的简化版本的对象。

Fake 是模拟被测系统所依赖的那个组件,它是生产环境下这个被依赖组件的功能实现的简化版本。Fake对象是用于测试的,但它既不是测试中的控制点,也不是观测点。

go test 命令介绍

  • 测试文件必须放在以 _test.go 后缀结尾的文件中,单测文件不会包含在 go build 的源码构建中。

  • 测试函数的函数名必须以大写的 Test 开头,后面紧跟的函数名,要么是大写开关,要么就是下划线,比如 func TestName(t *testing.T) 或者 func Test_name(t *testing.T) 都是 ok 的, 但是 func Testname(t *testing.T)不会被检测到。

  • 通常情况下,需要将测试文件和源代码放在同一个包内。一般测试文件的命名,都是 {source_filename}_test.go,比如我们的源代码文件是user.go ,那么就会在 user.go 的相同目录下,再建立一个 user_test.go 的单元测试文件去测试 user.go 文件里的相关方法。

  • 当运行 go test 命令时,go test 会遍历所有的 *_test.go 中符合上述命名规则的函数,然后生成一个临时的 main 包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

  • go test 只会在当前包中找测试函数,go test ./... 会遍历所有子目录找所有的测试函数

  • 参数 -v 可以打印详情

image

  • 参数 -cover 可以显示单测覆盖率

image

可以看到单测覆盖率不到 100%,进一步分析是哪里的代码没有测到。

go test -cover -coverprofile=cover.out -covermode=count
 #   注:
 #   -cover 允许代码分析
 #   -covermode 代码分析模式(set:是否执行;count:执行次数;atomic:次数,并发执行)
 #   -coverprofile 输出结果文件
 

然后再通过

go tool cover -func=cover.out

查看每个方法的覆盖率

image

还可以使用 html 的方式查看具体的覆盖情况

go tool cover -html=cover.out

会默认打开浏览器,将覆盖情况显示到页面中:

image

可以看到有两个方法的某个分支条件没有覆盖到,补充测试用例后再次执行

image

  • 测试单个文件 :通常,一个包里面会有多个方法,多个文件,因此也有多个 test 用例,假如我们想测试某一个文件中的方法时,需要指定具体的测试文件和被测文件

image

可以看到如果不指定被测文件,则会报错

  • 测试单个函数,如果想测试某个具体的函数,则需要指定函数名称,也可以正则匹配制定
# 精确匹配
go test -v operate_test.go operate.go -run TestHandle
# 正则匹配
go test -v operate_test.go operate.go -run "Handle"

image

测试覆盖率

测试的时候,我们常常关心,是否所有代码都测试到了。这个指标就叫做“代码覆盖率”(code coverage),它有四个测量维度:

  • 行覆盖率(line coverage):是否每一行都执行了?

  • 函数覆盖率(function coverage):是否每个函数都调用了?

  • 分支覆盖率(branch coverage):是否每个 if 代码块都执行了?

  • 语句覆盖率(statement coverage):是否每个语句都执行了?

go test --cover 指的是语句覆盖率

  • 覆盖率可以帮助我们识别出哪些代码没有测到

  • 覆盖率数据只能代表测试过哪些代码,不能代表是否测试好这些代码

  • 不能盲目追求代码覆盖率,而应该想办法设计更多更好的案例,哪怕多设计出来的案例对覆盖率一点影响也没有

框架介绍

常用框架

各框架对比:

框架 优点 缺点
base testing 官方自带的 testing 测试包,go 语言依赖 go test 命令和一组按照约定方式编写的测试函数,可以编写相对轻量级的测试代码。无断言,无mock。
断言 convey 分层级断言,同时也可以在网页中展示结果
testify 除了断言功能外,可使用suite包集成测试,配合monkey框架可以初始化与释放资源
mock gomock 可以在离线环境中测试,测试代码中无需手动初始化资源 需要通过实现接口方法的方式自动生成代码,也就是说如果代码风格不满足要求,需要修改业务代码,有一定的入侵性
gomonkey 一般可以mock所有的函数和方法 存在一定的局限性,例如非线程安全
mockito 与monkey使用比较相似,但是更加简便,功能更强大,并且并发安全,建议代替monkey

断言

golang 标准库不支持断言,而是通过 if 语句进行判断,使用t.Error等方法报告错误,至于golang标准库为什么没有提供断言的能力,官方在FAQ里面回答了这个问题:

Why does Go not have assertions?

Go doesn't provide assertions. They are undeniably convenient, but our experience has been that programmers use them as a crutch to avoid thinking about proper error handling and reporting. Proper error handling means that servers continue to operate instead of crashing after a non-fatal error. Proper error reporting means that errors are direct and to the point, saving the programmer from interpreting a large crash trace. Precise errors are particularly important when the programmer seeing the errors is not familiar with the code.

We understand that this is a point of contention. There are many things in the Go language and libraries that differ from modern practices, simply because we feel it's sometimes worth trying a different approach.

总体概括一下大意就是:“Go不提供断言,我们知道这会带来一定的不便,其主要目的是为了防止你们这些程序员在错误处理上偷懒。我们知道「不提供断言能力」是一个争论点,但是我们觉得有时值得尝试一下”。

convey

安装
go get github.com/smartystreets/goconvey
# 如果要使用GoConvey的web界面,这时需要提前安装GoConvey的二进制
go install github.com/smartystreets/goconvey@latest
常用方法
type C interface {
    // 每个测试用例必须使用Convey函数包裹起来,它的第一个参数为string类型的测试描述,第二个参数为测试函数的入参(类型为*testing.T),第三个参数为不接收任何参数也不返回任何值的函数(习惯使用闭包)
    // Convey函数的第三个参数闭包的实现中通过So函数完成断言判断
    // Convey语句可以无限嵌套,以体现测试用例之间的关系。需要注意的是,只有最外层的Convey需要传入*testing.T类型的变量t。
   Convey(items ...any)
   
   // SkipConvey函数表明相应的闭包函数将不被执行
   SkipConvey(items ...any)
   FocusConvey(items ...any)
   
    // 它的第一个参数为实际值,第二个参数为断言函数变量,第三个参数或者没有(当第二个参数为类ShouldBeTrue形式的函数变量)或者有(当第二个函数为类ShouldEqual形式的函数变量)
   So(actual any, assert Assertion, expected ...any)
   SoMsg(msg string, actual any, assert Assertion, expected ...any)
   
   // SkipSo函数表明相应的断言将不被执行
   SkipSo(stuff ...any)

   Reset(action func())

   Println(items ...any) (int, error)
   Print(items ...any) (int, error)
   Printf(format string, items ...any) (int, error)
}
  • **Skip : **针对想忽略但又不想删掉或注释掉某些断言操作,GoConvey提供了Convey/So的Skip方法:

  • **Focus: **FocusConvey 与 SkipConvey 具有相反的效果

示例

假设被测函数为:

// StringSliceEqual 判断两个 slice 是否相等 .
func StringSliceEqual(a, b []string) bool {
   if len(a) != len(b) {
      return false
   }

   if (a == nil) != (b == nil) {
      return false
   }

   for i, v := range a {
      if v != b[i] {
         return false
      }
   }
   return true
}

使用场景演示:

  • 基本用法
  • func TestStringSliceEqual(t *testing.T) {
       Convey("1⃣️级:TestStringSliceEqual", t, func() {
          Convey("2⃣️级:should return true when a != nil  && b != nil", func() {
             a := []string{"hello", "goconvey"}
             b := []string{"hello", "goconvey"}
             So(StringSliceEqual(a, b), ShouldBeTrue)
          })
    
          Convey("2⃣️级:should return true when a == nil  && b == nil", func() {
             Convey("3⃣️级 should return false - len(2)", func() {
                a := []string{"hello", "goconvey"}
                b := []string{"hello", "goconvey"}
                So(StringSliceEqual(a, b), ShouldBeTrue)
             })
             Convey("3⃣️级 should return false - len(3)", func() {
                a := []string{"hello", "goconvey", "go"}
                b := []string{"hello", "goconvey", "go"}
                So(StringSliceEqual(a, b), ShouldBeTrue)
             })
    
             Convey("3⃣️级 should return false - len(4)", func() {
                a := []string{"hello", "goconvey", "go", "go"}
                b := []string{"hello", "goconvey", "go", "go"}
                So(StringSliceEqual(a, b), ShouldBeTrue)
             })
    
             Convey("3⃣️级 should return false - len(5)", func() {
                a := []string{"hello", "goconvey", "go", "go", "go"}
                b := []string{"hello", "goconvey", "go", "go", "go"}
                So(StringSliceEqual(a, b), ShouldBeTrue)
             })
          })
       })
    }
    

    image

    • 可以看到通过 Convey 进行多级嵌套,可以很好的组织测试用例,输出也是多级嵌套的格式
  • skip 演示
  • func TestSkipStringSliceEqual(t *testing.T) {
       SkipConvey("TestStringSliceEqual should return true", t, func() {
          l1 := []string{"hello", "convey", "world"}
          l2 := []string{"hello", "convey", "world"}
          res := StringSliceEqual(l1, l2)
          So(res, ShouldBeTrue)
       })
       Convey("TestStringSliceEqual should return false", t, func() {
          l1 := []string{"hello", "convey", ""}
          l2 := []string{"hello", "convey", "world"}
          res := StringSliceEqual(l1, l2)
          Convey("二级:TestStringSliceEqual should return false", func() {
             So(res, ShouldBeFalse)
             So(res, ShouldBeFalse)
             SkipSo(res, ShouldBeTrue)
          })
       })
    }
    

    image

    • 可以看到通过Skip 包裹的断言都没有执行,并且在对应的 Convey 后面有 ⚠ 标识。
  • focus 演示
  • func TestFocusStringSliceEqual(t *testing.T) {
       FocusConvey("TestStringSliceEqual should return true", t, func() {
          l1 := []string{"hello", "convey", "world"}
          l2 := []string{"hello", "convey", "world"}
          res := StringSliceEqual(l1, l2)
          So(res, ShouldBeTrue)
       })
       FocusConvey("TestStringSliceEqual should return false", t, func() {
          l1 := []string{"hello", "convey", ""}
          l2 := []string{"hello", "convey", "world"}
          res := StringSliceEqual(l1, l2)
          FocusConvey("二级:TestStringSliceEqual should return false", func() {
             So(res, ShouldBeFalse)
             So(res, ShouldBeFalse)
             SkipSo(res, ShouldBeTrue)
          })
          Convey("二级:TestStringSliceEqual should return false", func() {
             So(res, ShouldBeFalse)
             So(res, ShouldBeFalse)
             SkipSo(res, ShouldBeTrue)
          })
       })
    }
    

    image

    • 可以看到如果顶层是FocusConvey,则嵌套结构中只有 FocusConvey 中的断言会被执行

    Web 界面

    goconvey 也支持界面进行单测执行,首先需要安装 goconvey 命令行工具,安装后执行

    # 在对应的项目目录下执行 
    goconvey
    # 可以通过 --help 查看一些帮助,根据情况指定参数
    

    image

    image

    testify

    testify主要提供了assert、require、mock和suite包,常用的就是assert、require、suite,区别是assert在断言为false时会后面的代码继续执行,require会终止。suite 提供了单测执行前后的Hook,可以用于构建/删除测试环境/数据等。mock 可以使用专门的mock 库,这里不做重点介绍,下面主要介绍 assert 和 suite 。

    假设被测函数为 :

    const num = 7
    // Mod7 return n mod 7 .
    func Mod7(n int) (int, error) {
       if n < 0 {
          return 0, fmt.Errorf("not support negative number, param is: %d", n)
       }
       return n % num, nil
    }
    
    assert

    主要使用各种断言函数

    如果不使用断言,测试代码可能是这样的

    func TestNotAssertMod7_1(t *testing.T) {
       num := 8
       res, err := Mod7(num)
       if err != nil {
          t.Fatal(err)
       }
       if res != 1 {
          t.Errorf("exp: 1, act: %d", res)
       }
    }
    
    func TestNotAssertMod7_2(t *testing.T) {
       num := -8
       res, err := Mod7(num)
       if err != nil {
          t.Fatal(err)
       }
       if res != 1 {
          t.Errorf("exp: 1, act: %d", res)
       }
    }
    

    用了断言是这样的

    func TestAssertMod7_1(t *testing.T) {
       num := 8
       res, err := Mod7(num)
       assert.NoError(t, err)
       assert.Equal(t, res, 1, "希望是相等的")
       assert.NotEqual(t, res, 2, "希望是不相等的")
    }
    
    func TestAssertMod7_2(t *testing.T) {
       num := -8
       res, err := Mod7(num)
       as := assert.New(t)
       as.Error(err)
       as.Contains(err.Error(), "not support negative number")
       as.Equal(res, 0)
    }
    

    看起来简洁很多

    suite

    testify提供了测试套件的功能(TestSuite),testify测试套件只是一个结构体,内嵌一个匿名的suite.Suite结构。测试套件中可以包含多个测试,它们可以共享状态,还可以定义钩子方法执行初始化和清理操作。钩子都是通过接口来定义的,实现了这些接口的测试套件结构在运行到指定节点时会调用对应的方法。

    type SetupAllSuite interface {
       SetupSuite()
    }
    

    如果定义了SetupSuite()方法(即实现了SetupAllSuite接口),在套件中所有测试开始运行前调用这个方法。对应的是TearDownAllSuite:

    type TearDownAllSuite interface {
       TearDownSuite()
    }
    

    如果定义了TearDonwSuite()方法(即实现了TearDownSuite接口),在套件中所有测试运行完成后调用这个方法。

    type SetupTestSuite interface {
       SetupTest()
    }
    

    如果定义了SetupTest()方法(即实现了SetupTestSuite接口),在套件中每个测试执行前都会调用这个方法。对应的是TearDownTestSuite:

    type TearDownTestSuite interface {
       TearDownTest()
    }
    

    如果定义了TearDownTest()方法(即实现了TearDownTest接口),在套件中每个测试执行后都会调用这个方法。

    还有一对接口BeforeTest/AfterTest,它们分别在每个测试运行前/后调用,接受套件名和测试名作为参数。

    下面通过一个示例看下

    
    type Mod7TestSuite struct {
       suite.Suite
    }
    
    func (m *Mod7TestSuite) SetupSuite() {
       m.T().Log("======================================= : SetupSuite")
    }
    
    func (m *Mod7TestSuite) SetupTest() {
       m.T().Log("======================================= : SetupTest")
    }
    
    func (m *Mod7TestSuite) TearDownSuite() {
       m.T().Log("======================================= : TearDownSuite")
    }
    
    func (m *Mod7TestSuite) TearDownTest() {
       m.T().Log("======================================= : TearDownTest")
    }
    
    func (m *Mod7TestSuite) BeforeTest(suiteName, testName string) {
       m.T().Logf("======================================= : BeforeTest, suiteName: [%s], testName: [%s]", suiteName, testName)
    }
    
    func (m *Mod7TestSuite) AfterTest(suiteName, testName string) {
       m.T().Logf("======================================= : AfterTest, suiteName: [%s], testName: [%s]", suiteName, testName)
    }
    
    func (m *Mod7TestSuite) HandleStats(suiteName string, stats *suite.SuiteInformation) {
       s, _ := json.MarshalIndent(stats, "", "  ")
       m.T().Logf("======================================= : HandleStats, suiteName: [%s], stats: n%s", suiteName, s)
    }
    
    //--------------------------------------------------------------------------------
    func (m *Mod7TestSuite) TestMod7_1() {
       m.T().Log("======================================= : TestMod7_1 Start")
       n := 8
       res, err := Mod7(n)
       as := assert.New(m.T())
       as.NoError(err)
       as.Equal(res, 1)
       m.T().Log("======================================= : TestMod7_1 End")
    }
    
    func (m *Mod7TestSuite) TestMod7_2() {
       m.T().Log("======================================= : TestMod7_2 Start")
       n := -8
       res, err := Mod7(n)
       as := assert.New(m.T())
       as.Error(err)
       as.Equal(res, 0)
       m.T().Log("======================================= : TestMod7_2 End")
    }
    
    func TestMod7TestSuite(t *testing.T) {
       suite.Run(t, new(Mod7TestSuite))
    }
    

    image

    通过输出可以看出他们之间的执行顺序。

    mock

    gomock

    测试代码收录在: code.byted.org/gaoweizong/…

    安装
    go get github.com/golang/mock/gomock
    # 下面用于安装 mockgen 工具
    go install github.com/golang/mock/mockgen@latest
    
    示例

    假设有一个接口需要mock

    package case1
    
    type User struct {
       Name string
    }
    
    type UserDao interface {
       GetUserById(id int) (*User, error)
    }
    

    通过 mockgen 命令生成mock 文件

    mockgen -source user.go -destination user_mock.go -package case1
    

    生成的mock 文件为 : user_mock.go , 通过tree 查看目录结构

    image

    指定 mock 具体返回值

    func TestReturn1(t *testing.T) {
       ctrl := gomock.NewController(t)
       defer ctrl.Finish()
    
       dao := NewMockUserDao(ctrl)
       dao.EXPECT().GetUserById(5).Return(&User{"tom"}, nil) // Return mock 返回值
       u, err := dao.GetUserById(5) 
       assert.NoError(t, err)
       assert.Equal(t, u.Name, "tom")
    }
    

    指定 mock 返回函数

    func TestReturn2(t *testing.T) {
       ctrl := gomock.NewController(t)
       defer ctrl.Finish()
    
       dao := NewMockUserDao(ctrl)
       dao.EXPECT().GetUserById(gomock.Any()).DoAndReturn(func(i int) (*User, error) { // 通过 func 函数返回
          t.Log("first exec func")
          if i == 0 {
             return nil, errors.New("user not found")
          }
          if i > 100 {
             return &User{Name: "大于100"}, nil
          } else {
             return &User{Name: "小于100"}, nil
          }
       })
       u, err := dao.GetUserById(5) // 任意值都有返回
       assert.NoError(t, err)
       assert.Equal(t, u.Name, "小于100")
    }
    

    指定调用次数

    
    func TestInvokerTimes(t *testing.T) {
       ctrl := gomock.NewController(t)
       defer ctrl.Finish()
       dao := NewMockUserDao(ctrl)
       dao.EXPECT().GetUserById(gomock.Any()).DoAndReturn(func(i int) (*User, error) { // 通过 func 函数返回
          t.Log("first exec func")
          if i == 0 {
             return nil, errors.New("user not found")
          }
          if i > 100 {
             return &User{Name: "大于100"}, nil
          } else {
             return &User{Name: "小于100"}, nil
          }
       }).Times(3) // 指定了调用次数,当调用次数不为 3 时,单测不通过
       u1, err := dao.GetUserById(-5)
       assert.NoError(t, err)
       assert.Equal(t, u1.Name, "小于100")
       u2, err := dao.GetUserById(0)
       assert.Error(t, err)
       assert.Nil(t, u2)
       u3, err := dao.GetUserById(105)
       assert.NoError(t, err)
       assert.Equal(t, u3.Name, "大于100")
    }
    

    指定调用顺序

    func TestOrder(t *testing.T) {
       ctrl := gomock.NewController(t)
       defer ctrl.Finish()
    
       dao := NewMockUserDao(ctrl)
       o1 := dao.EXPECT().GetUserById(1).Return(&User{"tom"}, nil)
       o2 := dao.EXPECT().GetUserById(2).Return(&User{"panny"}, nil)
       o3 := dao.EXPECT().GetUserById(3).Return(&User{"join"}, nil)
       t.Log(o1, o2, o3)
    
       gomock.InOrder(o1, o2, o3) // 必须按顺序进行获取 ,否则下面的测试不通过
       t.Log(dao.GetUserById(2))
       t.Log(dao.GetUserById(1))
       t.Log(dao.GetUserById(3))
    }
    

    gomonkey

    基本原理

    monkey patch 就是在运行时,动态修改一些变量/函数/方法/模块 的行为的能力。对于有些三方的库,我们没有权限去调整代码逻辑,而这又会对我们测试带来影响,所以,我们通过【运行时替换】的方法来改掉这些实体的行为。

    gomonkey 就是在 Golang 下对 monkey patching 进行支持的测试库,一个打桩框架。目标是让用户在单元测试中低成本的完成打桩,从而将精力聚焦于业务功能的开发。

    示例

    假设被测代码为 :

    
    var (
       addVarMonkey = 10
       addFunMonkey = func(x int) int { return x + addConstMonkey }
    )
    
    const (
       addConstMonkey = 100
    )
    
    func add(a, b int) (int, error) {
       return a + b, nil
    }
    
    func Add5OnlyPositiveNumber(x int) (int, error) {
       num := 5
       if x < num {
          return 0, fmt.Errorf("")
       }
       return add(x, num)
    }
    
    type Number struct {
       star int
    }
    
    func (n *Number) Add(x int) int {
       return x + n.star
    }
    
    func (n *Number) Sub(x int) int {
       return x - n.star
    }
    
    func (n *Number) Mutil(x int) int {
       x = n.Add(x)
       x = n.Add(x)
       x = n.Sub(x)
       return x
    }
    

    给全局变量****打桩

    func TestApplyGlobalVar(t *testing.T) {
       Convey("TestApplyGlobalVar", t, func() {
          Convey("先修改看看", func() {
             targetPatch := 11
             patches := gomonkey.ApplyGlobalVar(&addVarMonkey, targetPatch) // 使用 reflect 包,将 target 的值修改为 double
             defer patches.Reset()
             So(addVarMonkey, ShouldEqual, targetPatch)
          })
    
          Convey("恢复后看看", func() {
             So(addVarMonkey, ShouldEqual, 10)
          })
       })
    }
    

    给变量函数****打桩

    func TestApplyFuncVar(t *testing.T) {
       Convey("TestApplyFuncVar", t, func() {
          Convey("先修改看看", func() {
             patches := gomonkey.ApplyFuncVar(&addFunMonkey, func(_ int) int { return 20 })
             defer patches.Reset()
             act := addFunMonkey(23)
             So(act, ShouldEqual, 20)
          })
    
          Convey("恢复后看看", func() {
             act := addFunMonkey(23)
             So(act, ShouldEqual, 123)
          })
       })
    }
    

    给函数****打桩

    func TestApplyFunc(t *testing.T) {
       Convey("TestApplyFunc", t, func() {
          Convey("先修改看看", func() {
             patches := gomonkey.ApplyFunc(add, func(a, b int) (int, error) { return 100, nil })
             defer patches.Reset()
             sum, err := Add5OnlyPositiveNumber(10)
             t.Logf("sum: %d", sum)
             So(err, ShouldBeNil)
             So(sum, ShouldEqual, 100)
          })
    
          Convey("恢复后看看", func() {
             sum, err := Add5OnlyPositiveNumber(10)
             t.Logf("sum: %d", sum)
             So(err, ShouldBeNil)
             So(sum, ShouldEqual, 15)
          })
       })
    }
    

    执行下看看 go test

    image

    可以看到这里断言失败了,说明mock没有生效,这个问题一般是内联导致的。

    内联:golang编译器在编译时会进行内联优化,即把简短的函数在调用它的地方展开,从而消除调用目标函数的开销。但因为内联消除了调用目标函数时的跳转操作,使得go monkey填充在目标函数入口处的指令无法执行,因而也无法实现函数体的运行时替换,使go monkey失效。所以,执行测试 case 前一定要注意加上 -gcflags=all=-l

    禁用下内联再看下 go test -gcflags=all=-l

    image

    给方法****打桩

    func TestApplyMethod(t *testing.T) {
       Convey("TestApplyMethod", t, func() {
          Convey("先修改看看", func() {
             var nnn *Number
             patches := gomonkey.ApplyMethod(reflect.TypeOf(nnn), "Mutil", func(_ *Number, x int) int {
                return 100
             })
             defer patches.Reset()
             cp := &Number{
                star: 10,
             }
             mutil := cp.Mutil(19)
             t.Logf("mutil: %d", mutil)
             So(mutil, ShouldEqual, 100)
          })
    
          Convey("恢复后看看", func() {
             cp := &Number{
                star: 10,
             }
             mutil := cp.Mutil(19)
             t.Logf("mutil: %d", mutil)
             So(mutil, ShouldEqual, 29)
          })
       })
    }
    

    给函数打桩执行序列

    func TestApplyFuncSeq(t *testing.T) {
       Convey("TestApplyFuncSeq", t, func() {
          Convey("先修改看看", func() {
             outputs := []gomonkey.OutputCell{
                {Values: gomonkey.Params{70, nil}},           // Values: 函数返回值,需要和mock 函数返回执行相同
                {Values: gomonkey.Params{80, nil}, Times: 2}, // Times:执行次数
                {Values: gomonkey.Params{0, fmt.Errorf("x is < 100")}, Times: 2},
                {Values: gomonkey.Params{90, nil}, Times: 2},
             }
             patches := gomonkey.ApplyFuncSeq(add, outputs)
             defer patches.Reset()
             a1, err := Add5OnlyPositiveNumber(7)
             So(err, ShouldBeNil)
             So(a1, ShouldEqual, 70)
    
             a2, err := Add5OnlyPositiveNumber(7)
             Add5OnlyPositiveNumber(7)
             So(err, ShouldBeNil)
             So(a2, ShouldEqual, 80)
    
             a3, err := Add5OnlyPositiveNumber(7)
             Add5OnlyPositiveNumber(7)
             So(err, ShouldBeError)
             So(a3, ShouldEqual, 0)
    
             a4, err := Add5OnlyPositiveNumber(7)
             Add5OnlyPositiveNumber(7)
             So(err, ShouldBeNil)
             So(a4, ShouldEqual, 90)
    
             //a5, err := Add5OnlyPositiveNumber(7) // 上面定义了 outputs 的长度为 7 ,调用次数超过 7 就会报错
             //So(err, ShouldBeNil)
             //So(a5, ShouldEqual, 90)
          })
    
          Convey("恢复后看看", func() {
             a1, err := Add5OnlyPositiveNumber(7)
             So(err, ShouldBeNil)
             So(a1, ShouldEqual, 12)
    
             a2, err := Add5OnlyPositiveNumber(0)
             So(err, ShouldBeError)
             So(a2, ShouldEqual, 0)
          })
       })
    }
    

    mockito

    mockito 是 公司内研发的mock 框架,现在已经开源,推荐使用开源版本。找到了一些写的比较详细的文档供参考:

    下面贴一些个人使用demo

    code.byted.org/gaoweizong/…

    Mock 函数并指定返回值

    假设被测函数为:

    func add(a, b int) (int, error) {
       return a + b, nil
    }
    
    func Add5OnlyPositiveNumber(x int) (int, error) {
       num := 5
       if x < num {
          return 0, fmt.Errorf("")
       }
       return add(x, num)
    }
    

    单测为:

    func TestFuncReturn(t *testing.T) {
       PatchConvey("test func return", t, func() {
          PatchConvey("先修改看看", func() {
             Mock(add).Return(10, nil).Build()
             act, err := Add5OnlyPositiveNumber(10)
    
             convey.So(err, convey.ShouldBeNil)
             convey.So(act, convey.ShouldEqual, 10)
          })
          PatchConvey("恢复后看看", func() {
             act, err := Add5OnlyPositiveNumber(10)
             convey.So(err, convey.ShouldBeNil)
             convey.So(act, convey.ShouldEqual, 15)
          })
       })
    }
    

    执行一下看看: go test -v -gcflags=all=-l add.go add_test.go num.go -run TestFuncReturn

    image

    可以看到 mock 失败了,由于被mock 的函数太短了,详细原因参考 浅谈 Golang 中的猴子补丁 —— 以 mockey 为例

    改进: 被mock 的函数最少两行以上才行:

    func add(a, b int) (int, error) {
       fmt.Println("如果函数太短,会mock 失败,并报错:function is too short to patch ,所以增加一行打印日志")
       return a + b, nil
    }
    

    再次执行可以执行通过

    image

    Mock 函数并指定返回函数

    func TestFuncTo(t *testing.T) {
       PatchConvey("test func to", t, func() {
          PatchConvey("先修改看看", func() {
             Mock(add).To(func(a, b int) (int, error) {
                if a > 10 {
                   return 20, nil
                }
                return 30, nil
             }).Build()
    
             act1, err := Add5OnlyPositiveNumber(15)
             convey.So(err, convey.ShouldBeNil)
             convey.So(act1, convey.ShouldEqual, 20)
    
             act2, err := Add5OnlyPositiveNumber(8)
             convey.So(err, convey.ShouldBeNil)
             convey.So(act2, convey.ShouldEqual, 30)
    
          })
          PatchConvey("恢复后看看", func() {
             act, err := Add5OnlyPositiveNumber(10)
             convey.So(err, convey.ShouldBeNil)
             convey.So(act, convey.ShouldEqual, 15)
          })
       })
    }
    

    Mock 方法

    func TestMethodTo(t *testing.T) {
       PatchConvey("test method to", t, func() {
          PatchConvey("先修改看看", func() {
             Mock((*Number).Add).To(func(x int) int {
                if x > 10 {
                   return 20
                }
                return 30
             }).Build()
             num := Number{
                star: 10,
             }
             act1 := num.Mutil(15)
             convey.So(act1, convey.ShouldEqual, 10)
    
             act2 := num.Mutil(8)
             convey.So(act2, convey.ShouldEqual, 10)
          })
          PatchConvey("恢复后看看", func() {
             num := Number{
                star: 10,
             }
             act := num.Mutil(10)
             convey.So(act, convey.ShouldEqual, 20)
          })
       })
    }
    

    Mock by when(有条件的Mock)

    func TestMethodWhen(t *testing.T) {
       PatchConvey("test method when", t, func() {
          PatchConvey("先修改看看", func() {
             Mock((*Number).Add).When(func(x int) bool {
                if x > 0 {
                   return true
                }
                return false
             }).To(func(x int) int {
                if x > 10 {
                   return 20
                }
                return 30
             }).Build()
             num := Number{
                star: 10,
             }
             act1 := num.Mutil(15)
             convey.So(act1, convey.ShouldEqual, 10)
    
             act2 := num.Mutil(8)
             convey.So(act2, convey.ShouldEqual, 10)
    
             act3 := num.Mutil(-88) // 这里不应该被 mock 到
             convey.So(act3, convey.ShouldEqual, -78)
          })
          PatchConvey("恢复后看看", func() {
             num := Number{
                star: 10,
             }
             act := num.Mutil(10)
             convey.So(act, convey.ShouldEqual, 20)
          })
          Sequence()
       })
    }
    

    Mock 全局变量

    func TestVarReturn(t *testing.T) {
       PatchConvey("test var return", t, func() {
          PatchConvey("先修改看看", func() {
             MockValue(&addVarMonkey).To(100) // 注意: 这里用的是 MockValue , Mock 用于Mock 函数
             convey.So(addVarMonkey, convey.ShouldEqual, 100)
          })
          PatchConvey("恢复后看看", func() {
             convey.So(addVarMonkey, convey.ShouldEqual, 10)
          })
          Sequence()
       })
    }
    

    mock 一个私有struc 的 public 方法

    func TestPrivateMethod(t *testing.T) {
       PatchConvey("test private method", t, func() {
          PatchConvey("先修改看看", func() {
             // GetMethod 参数:
             // - Instance 含有多层嵌套匿名struct的struct实例
             // - methodName 对应函数名(必须是public函数)
             target := GetMethod(user.UserInstance, "GetName")
             Mock(target).Return("tom").Build()
    
             name := user.UserInstance.GetName()
             convey.So(name, convey.ShouldEqual, "tom")
          })
          PatchConvey("恢复后看看", func() {
             name := user.UserInstance.GetName()
             convey.So(name, convey.ShouldEqual, "jeff")
          })
       })
    }
    

    推荐

    优先推荐使用: testing + goconvey(用于用例管理 & 断言) **+ **mockito(函数 mock 框架)。在出现上述工具不适用的情形时,可自主选用其它工具进行补充。

    思考

    • 不管黑猫白猫,抓住老鼠才是好猫,单测也是一样,本质是对代码质量的保证,如果对自己的代码质量有自信,不用单测也是可以的。

    • 对于单测不要设置指标,没有对代码没有信心,那就增加单测。

    • 心中有单测的理念,可以帮助我们写出更模块化的代码。

    • 能写单测的代码,就是好代码。

    • 有单测不能保证代码质量高,但没单测一定不能保证代码质量高。

    参考

    • 搞定Go单元测试(一)——基础原理 - 掘金

    • bytetech.info/articles/72…

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论