什么样的代码单元测试行不通
-
先来看一段代码
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掉依赖,第二步对逻辑写单元测试。这用外部依赖往往是以下几种情况:
- 数据库
- 网络请求
- 操作系统交互
- 至于没有依赖,逻辑也非常简单的情况,我觉得是不需要强制追求测试覆盖率的。