Go单元测试

626 阅读10分钟

什么样的代码单元测试行不通

  • 先来看一段代码

    import 外包公司
    
    type CXK struct {}
    
    // 坤坤的表演内容为唱、跳、rap、篮球和摆pose
    // 但是坤坤只会摆pose,其他的表演项只能找替身
    func (c *CXK) Show() {
      // 找替身
      singer := 外包公司.FindSinger()
      dancer := 外包公司.FindDancer()
      rapper := 外包公司.FindRapper()
      basketballPlayer := 外包公司.FindBasketballPlayer()
      
      // 替身表演阶段
      singApplause := singer.Sing()
      danceApplause := dancer.Dance()
      rapApplause := rapper.Rap()
      playApplause := basketballPlayer.Play()
      
      // 坤坤表演阶段
      // 坤坤摆pose的时间长短由各位替身表演的掌声决定,观众反应越好坤坤摆pose越投入
      if singApplause+danceApplause+rapApplause+playApplause > 100 {
        	// pose5分钟
      } else {
        	// pose1分钟
      }
    }
    
  • 以上的做法有这样几个弊端

    • 坤坤的替身只能由外包公司给他找,不满意还换不了。

    • 彩排时,替身没来的话,也就是singer等这些为nil时,坤坤就完全没法表演。

    • 彩排时,坤坤一定得等着替身全部表演完,然后才能摆pose,很浪费坤坤宝贵的时间。

  • 将这个例子映射到实际的项目代码中

    • 像坤坤依赖替身一样,项目中有些功能也是依赖外部服务。比如有个功能需要对身份证做ocr识别,然后对ocr识别结果做提取再返回给前端。如果我们写一个函数:

      • import ocrSDK
        
        func ExtractIDNumber(idCardFile io.Reader) (idNumber string) {
            // 获取一个ocr实列
            ocr := ocrSDK.New(config)
            // ocr识别
            result := ocr.Do(idCardFile)
           	// 提取身份证号码
            idNumber = ...
            return 
        }
        
      • 类比之前提到的弊端

        • 方法中获取的ocr实列由ocrSDK提供,换其他供应商的ocr对代码的改动很大。
        • 一般来说,ocr是需要对模板做训练的,而我们的开发工作也不能等ocr全部弄好之后再进行。但是这种写法在ocr没有提供好之前,我们无法测试提取身份证号码这部分逻辑。
        • 等到ocr提供好之后,测试时,我们必须等到ocr做完识别,才能执行提取身份证号码这部分逻辑,这是没有必要的,特别是做比较耗时的ocr识别时。
  • 总结

    • 以上的情况其实都是将开发时所需的外部依赖设计的太不灵活,导致扩展很麻烦,测试也很不方便。而采用依赖注入的方式可以很大程度提升灵活性。

依赖注入

  • 为了实现目的,第1步就是依赖注入。依赖注入简单来说就是将写在方法内部的依赖获取,改为以参数形式从外面传进来。

  • 版本1:

    • type CXK struct {
        Singer Eason
        Dancer ZhaoSi
        Rapper KeyL
        BasketballPlayer Kobe
      }
      
      // CXK的构造函数接受替身参数
      func NewCXK(e Eason,z ZhaoSi,l KeyL,k Kobe) *CXK {
        return &CXK{
          Singer:e,
          Dancer:z,
          Rapper:l,
          BasketballPlayer:k,
        }
      }
      
      func (c *CXK) Show() error {
        // 替身表演阶段
        singApplause := c.Singer.Sing()
        danceApplause := c.Dancer.Dance()
        rapApplause := c.Rapper.Rap()
        playApplause := c.BasketballPlayer.Play()
        
        // 坤坤表演阶段
        // 坤坤摆pose的时间长短由各位替身表演的掌声决定,观众反应越好坤坤摆pose越投入
        total := singApplause+danceApplause+rapApplause+playApplause
        if total > 100 {
          // pose5分钟
           return nil
        } else if total > 50 {
          // pose1分钟
           return nil
        } else {
        	return errors.New("很不开心") 
        }
      }
      
      
      
    • 这样坤坤来表演的时候自己就自带了四位高手,就不依赖外包公司临场帮忙找了。至于这四位从哪里找,他可以自己找朋友或者找一家更靠谱的外包公司帮忙,至少他有了选择。

    • 但这样也有问题,如果kobe来不了,坤坤的表演又黄了。

  • 版本2

    • type Singer interface {
        Sing() int
      }
      
      type Dancer interface {
        Dance() int
      }
      
      type Rapper interface {
        Rap() int
      }
      
      type BasketballPlayer interface {
        Play() int
      }
      
      type CXK struct {
        Singer Singer 
        Dancer Dancer
        Rapper Rapper
        BasketballPlayer BasketballPlayer
      }
      
      func NewCXK(s Singer,d Dancer,r Rapper,b BasketballPlayer) *CXK {
        return &CXK{
          Singer:s,
          Dancer:d,
          Rapper:r,
          BasketballPlayer:b,
        }
      }
      
      func (c *CXK) Show() error {
          // 替身表演阶段
          singApplause := c.Singer.Sing()
          danceApplause := c.Dancer.Dance()
          rapApplause := c.Rapper.Rap()
          playApplause := c.BasketballPlayer.Play()
        	// 坤坤表演阶段
      	// 坤坤摆pose的时间长短由各位替身表演的掌声决定,观众反应越好坤坤摆pose越投入
      	total := singApplause+danceApplause+rapApplause+playApplause
      	if total > 100 {
        	    // pose5分钟
        	    return nil
      	} else if total > 50 {
        	    // pose1分钟
         	    return nil
      	} else {
        	    return errors.New("很不开心") 
      	}
      }
      
  • 这回坤坤的依赖全部变成了接口类型,这样坤坤就不在依赖一个具体的人了,只要具备能力都可以注入。

  • 依赖注入更加具有面向接口的思想。扩展性变强了,也写新方法时减少了很多和主逻辑不相关的代码(比如之前的FindSinger之类的方法),让代码清晰可读。

  • 依赖注入会带来一个问题:在项目越来越大的时候,A依赖了很多,A的依赖又依赖了很多,A依赖的依赖又依赖了很多......那A的构造函数会非常难写。google开源了一个工具wire,能很大程度上减少这个工作量,强烈安利传送门

mock

  • 到了这里就要回归到单元测试的正题了,首先提出一个概念:mock!

  • 如果完全不了解什么是mock的话,可以先阅读下这篇文章。简单来说,mock就是能够模仿真实对象行为的模拟对象。举个例子,坤坤只想彩排摆pose这个环节,这时候找真正的歌手不划算,反正坤坤最后想要的是唱歌获得了多少掌声而不是唱歌的过程,随便找个人来假唱然后模拟一个掌声值就可以了。这就是mock。

两个重要的作用

  • 在测试阶段,有了mock,就能在真正的外部服务没有准备好的时候,测试自己开发的逻辑。在ocr的那个例子中,就可以测试提取身份证号那段逻辑了。
  • mock是假的,所以结果值可以自己随意指定。假如坤坤找的4个替身都非常强,每次都能获得大于100的掌声值,这样代码中的else就命中不了。彩排时这种场景覆盖不了,如果正式表演的时候歌手嗓子哑了,篮球运动员手感不好,那掌声值就会低于100,而这个case又是没彩排过的,就很容易出问题。而mock就能帮助我们在测试阶段覆盖所有的场景。

mock示例

  • mock就是在测试包中创建一个结构体,实现某个外部依赖的接口。(看吧,如果不设计成接口,都没办法mock)

  • 把Singer给mock掉

    • type Singer interface {
        Sing() int
      }
      
      // 真正的歌手需要话筒
      type Eason struct {
        话筒
      }
      
      func (e *Eason) Sing() int {
        // 真正的唱歌
        // 1.清嗓子
        // 2.调话筒
        // ......
        return 真实的掌声值
      }
      
      // mock的歌手啥都不需要,越简单越好
      type MockSinger struct {}
      
      func (m *MockSinger) Sing() int {
        // 啥也没干
        return 随便写一个掌声值
      }
      
      • 从代码中可以看到mock越简单越好,mock结构体内部不需要参数,实现方法也不需要有逻辑。
  • 现在将整个过程简化,坤坤的表演只包含两个部分,唱和摆pose。下面来看一下如何使用mock来覆盖Show方法中的三个case。

    • import (
      	"testing"
      )
      
      // mock一个牛逼的歌手
      type MockNBSinger struct {}
      
      func (m *MockNBSinger) Sing() int {
        return 101
      }
      
      // mock一个一般的歌手
      type MockNormalSinger struct {}
      
      func (m *MockNormalSinger) Sing() int {
        return 51
      }
      
      // mock一个菜鸡的歌手
      type MockCJSinger struct {}
      
      func (m *MockCJSinger) Sing() int {
        return 1
      }
      
      // 测试Show方法
      func Test_CXK_Show(t *testing.T) {
        type fields struct {
      	    Singer Singer
      	}
        // 表格测试
      	tests := []struct {
      		name    string
      		fields  fields
      		wantErr bool
      	}{
      		{
      			name: "掌声值大于100的场景",
      			fields: fields{
              		Singer: &MockNBSinger{}, // 注入一个牛逼的歌手
      			},
      			wantErr: false,
      		},
      		{
      			name: "掌声值小于100,大于50的场景",
      			fields: fields{
              		Singer: &MockNormalSinger{}, // 注入一个一般的歌手
      			},
      			wantErr: false,
      		},
      		{
      			name: "掌声值小于50的场景",
      			fields: fields{
              		Singer: &MockCJSinger{}, // 注入一个菜鸡的歌手
      			},
      			wantErr: true,
      		},
      	}
      	for _, tt := range tests {
      		t.Run(tt.name, func(t *testing.T) {
      			c := &CXK{
      				Singer: tt.fields.Singer,
      			}
      			err := r.Show(tt.args.id)
      			if (err != nil) != tt.wantErr {
      				t.Errorf("Show() error = %v, wantErr %v", err, tt.wantErr)
      				return
      			}
      		})
      	}
      }
      
      • 这样通过mock的方式,就不需要等真正的歌手就绪好,也可以模拟所有的case,但是这里的问题是写的很麻烦,针对不同场景需要不同的mock,能不能用一个mock来做到呢?这就需要stub,打桩。

stub

  • mock是模拟一个结构体,而stub就是模拟一个方法。

  • 还是从上诉例子来

    • type MockSinger struct {
        SingFn func() int
      }
      
      func (m *MockSinger) Sing() int {
        return m.SingFn()
      }
      
    • 这里的mock结构体内部多了个函数,这可以让调用者自己传入一个符合他自己期望结果的函数。那测试用例可以这样写

      import (
      	"testing"
      )
      
      type MockSinger struct {
        SingFn func() int
      }
      
      func (m *MockSinger) Sing() int {
        return m.SingFn()
      }
      
      // 测试Show方法
      func Test_CXK_Show(t *testing.T) {
        type fields struct {
      	    Singer Singer
      	}
        // 表格测试
      	tests := []struct {
      		name    string
      		fields  fields
      		wantErr bool
      	}{
      		{
      			name: "掌声值大于100的场景",
      			fields: fields{
              		Singer: &MockSinger{SingFn:func() int {return 101}}, // 传入一个返回101的函数
      			},
      			wantErr: false,
      		},
      		{
      			name: "掌声值小于100,大于50的场景",
      			fields: fields{
              		Singer: &MockSinger{SingFn:func() int {return 51}}, // 传入一个返回51的函数
      			},
      			wantErr: false,
      		},
      		{
      			name: "掌声值小于50的场景",
      			fields: fields{
              		Singer: &MockSinger{SingFn:func() int {return 1}}, // 传入一个返回1的函数
      			},
      			wantErr: true,
      		},
      	}
      	for _, tt := range tests {
      		t.Run(tt.name, func(t *testing.T) {
      			c := &CXK{
      				Singer: tt.fields.Singer,
      			}
      			err := r.Show(tt.args.id)
      			if (err != nil) != tt.wantErr {
      				t.Errorf("Show() error = %v, wantErr %v", err, tt.wantErr)
      				return
      			}
      		})
      	}
      }
      
      • 通过stub,就不需要在mock多个Singer了。

go中如何快速的mock

  • 上文举例说明了如何使用mock+stub来做单元测试。但是现在的问题是太麻烦了,每天福报都修不完,写完业务代码了还得写mock。

  • 但是强大的gomock库可以自动生成这些mock和stub!gomock有详细的使用介绍,极易上手。

关于单元测试的一些个人建议

  • 以下都是个人建议,在具体项目中需要具体分析。

单元测试的要求

  • 单元测试的用例写完之后是需要维护的,逻辑变动时,测试用例也得做相应的修改。

  • 单元测试应该是每次都能跑通的,并且具有幂等性。

    • 如果将所有的外部依赖全部mock,是一定能做到幂等的。
    • 但是如果需要去测试sql的运行,要连接到真正的数据库,一般情况下,安排好增删查改的顺序,应该也是可以做到基本不留痕的测试的。

什么地方需要单元测试

  • 我觉得这要从两个维度考虑,依赖多少和代码复杂性。
    • 依赖:上文主要都是在讨论有依赖的情况要如何做测试,真实开发中也有很多场景是没有外部依赖的,这是降低了测试难度的。
    • 代码复杂性:每一个函数的目的不一样,自然复杂性也不一样的。复杂逻辑容易出错需要测试,但也有一些非常简单的逻辑,可以考虑不测试。
  • 复杂的代码,依赖又比较少的情况,比如一些算法等。这些极易出错,一定要写单元测试。
  • 复杂且依赖多的代码,就要分成两步来做。第一步mock掉依赖,第二步对逻辑写单元测试。这用外部依赖往往是以下几种情况:
    • 数据库
    • 网络请求
    • 操作系统交互
  • 至于没有依赖,逻辑也非常简单的情况,我觉得是不需要强制追求测试覆盖率的。