单元测试

2017-11-07 fishedee 后端

1 概述

单元测试

2 原理

2.1 定位

单元测试的出发点在于你每次新增功能或重构代码后,你并不确定你的改动会不会引入新的bug。所以,单元测试是通过开发者写入一份自测的代码来保证开发代码和单元测试代码都是没有问题的。每次改动代码以后,都需要运行一次完整的单元测试以保证代码没有出现一些低级的问题。

所以,单元测试总是站在测试金字塔的底端,它是快速的,可靠的,局部函数的测试。成本最低,同时也是最有可能测试出低级错误的一种技术。随着寻找错误的难度越来越高,我们就需要引入更加复杂的功能测试,端到端测试的技术了。反过来说,如果我们没有单元测试,只有功能测试和端到端测试的技术,那么像数组越界,漏输出字段这些低级错误就只能依赖功能测试和端到端测试的技术来找出来,这些技术无一不是需要非常严格完备的测试环境,以及特别高的人力和时间成本。

2.2 误区

单元测试是快速的,可靠的,局部函数的测试,这意味着它测试的目标是以函数为粒度的,但不要陷入误区,把所有的public函数都进行测试。因为单元测试的关键在于,只测试你觉得最有可能出错的部分,而不是测试所有部分。那些一眼看上去就是简单的CURD操作就没有必要进行单元测试,浪费时间而且成效不大。从另外一个角度看,你甚至可以只去测试一个类中的私有函数,只要你觉得这个私有函数是这个类中起最关键而且是最容易出错的地方。

另外,单元测试是函数为粒度的,它希望尽可能将函数以外的外部依赖都隔离掉,单纯地测试这个函数的功能。这就需要我们使用fake,stub和mock的技术来隔离外部依赖,这需要开发代码时就细致地考虑如何模块化和减少依赖,提高代码的可测性。

2.3 指标

单元测试的一个指标是代码覆盖率,常见的要求是单元测试的代码覆盖率在80%以上。可是,覆盖得越多不代表越可靠,过分追求代码覆盖率只会让单元测试形式化,没有起到效果。

所以,推荐放开单元测试代码覆盖率的指标,而改用功能测试中发现低级bug的指标。

2.4 原则

单元测试的基本原则:

  • 自测,由代码开发者写单元测试代码,促进开发者自身写出更具有可测性的代码
  • 快速,避免建立庞大的测试环境才能测试一个函数
  • 可靠,避免有时候成功有时候失败的单元测试。例如,避免依赖网络和IO环境的单元测试。
  • 简单,避免依赖于其他单元测试顺序的的用例,每个单元测试应该是自恰的。
  • 回归,每个低级bug发生后都应该添加相应的单元测试用例,而且,每次改动代码后都需要将全部单元测试跑一遍才能提交,这个要求可以交给持续部署组件来自动化实现。
  • 函数粒度,不要将过多的依赖串起来一次进行单元测试,这不仅容易失败,而且测试的时间也特别长。

3 技术

3.1 逻辑分离

func GetAnalyse(){
    data1 := this.GetDb()
    //doSomething
    for i := 0 ; i != len(xxx) ;i++{
        //doSomething
        data2 := this.GetDb2()
    }
    //doSomething
    return result
}

对于GetAnalyse函数,可测性就低一点了,因为其混入了两个依赖,GetDb()和GetDb2(),测试时都需要同时部署好两个Db的数据才能进行。但是,对于我们来说,最容易出错的地方是doSomething,而不是获取db数据的地方,所以我们将这段代码改为:

func GetAnalyse(){
    data1 := this.GetDb()
    data2 := this.GetDb2()
    return doSomething(data1,data2)
}

func doSomething(data1,data2){
    //doSomehing code
    return result
}

这样的话我们就只需要测试doSomething代码就可以了,doSomething外部依赖更少,测试更简单快速可靠。也许你会说,这样会漏掉this.GetDb和this.GetDb2的调用失败的用例。这样说是没有问题的,只是比起测试的成本来说,只测doSomething比全部测成效比更高,因为this.GetDb与this.GetDb2出错的可能性实在是太低了。

3.2 fake

func getUserById(id int)[]User{
    result := mysql.Query('select * from t_user where id = ?',id)
    //other code
    return result
}

但是在dao的代码中,我们的单元测试目的就是要测试sql代码写得有没有问题。这个时候我们就是需要不可避免地拉取数据库来执行测试,而且不能进行逻辑隔离。这样做不是说不行,只是速度太慢,而且不太可靠,因为网络抖动可能会导致单元测试失败。所以,fake的想法是让本地嵌入式的数据库来代替网络中的数据库,例如是sqlite或者h2数据库来代替mysql,这样的话速度更快,因为网络问题失败的可能性也低得多,当然对于标准的sql语句来说,这样的改变是没有影响测试结果的。

3.3 stub

func GetDb(){
    xxxx
}

func GetDb2(){
    xxxx
}

func GetAnalyse(){
    data1 := GetDb()
    //doSomething
    for i := 0 ; i != len(xxx) ;i++{
        //doSomething
        data2 := GetDb2()
    }
    //doSomething
    return result
}

还是GetAnalyse的例子,依赖太多,单元测试太麻烦,无法进行,而且当GetDb操作也变得复杂时,我们就无法使用逻辑分离的技巧了。我们只能老老实实地需要去测试整个GetAnalyse函数。但是,更悲哀的是,如果GetDB和GetDb2是别人写的模块,而这个模块还没完成时,就无法进行自测了。

var GetDb = func(){
    xxxx
}

var GetDb2 = func(){
    xxxx
}

var GetAnalyse = func(){
    data1 := GetDb()
    //doSomething
    for i := 0 ; i != len(xxx) ;i++{
        //doSomething
        data2 := GetDb2()
    }
    //doSomething
    return result
}

stub的想法是,既然没有依赖项,那我就自己构造一个固定输出的依赖项不就好了。首先,将GetDb和GetDb2改为可替换的函数指针变量,而不是固定的函数地址。

func TestGetAnalyse{
    GetDb = func(){
        //xxxx
    }
    GetDb2 = func(){
        //xxxx
    }
    //Test GetAnalyse
}

然后在测试时,动态修改GetDb和GetDb2的内容,让GetAnalyse在单元测试运行时指向到自己实现的函数中,这样就能起到无需依赖方配合只测试自己的目的了。当然,对于依赖项不是函数,而是类时,你就应该将依赖项改为接口,而不是实际的类就可以了。这也是传说中的“面向接口编程,而不是面向具体实现编程”的意义了。

3.4 mock

mock其实就是一种更加高级的stub而已。从刚才的例子可以看出,stub有点傻,无论什么样的输入都是返回一样的输出。我们希望替换的对象更加智能,它不仅可以根据不同的输入返回不同的结果,而且还能根据第几次的输入返回不同的结果,甚至还能校验被测函数是否正确调用了替换对象的次数。

type Talker interface {
    SayHello(word string)(response string)
}
type MM struct{
    talker Talker
}
func TestMM(t *testing.T){
    ctl := gomock.NewController(t)
    defer ctrl.Finish()
    
    mock_talker := mock_hellomock.NewMockTalker(ctl)
    mock_talker.EXPECT().SayHello(gomock.Eq("abc")).Return("def")
    mm := &MM{talker:mock_talker}
    //xxxx
}

这是一段使用gomock技术的代码,其中EXPECT那一段就是用来指定mock行为的。

当然,如此强大的mock并不是单元测试的银弹。其主要问题是,过度mock使用会让测试代码变得复杂,脆弱,一旦代码实现变动后,使用mock的测试代码就要相应改变,导致单元测试难以长久维护下去。

4 场景

4.1 DAO测试

DAO的测试主要问题是数据库慢,测试数据集多。Java的解决方案是:

  • h2嵌入式内存数据库加速
  • dbunit初始化数据库以及清理测试带来的数据库变动
  • Unitils简化导入测试数据集

4.2 AO测试

AO的测试主要问题是代码不规范,导致过度依赖,而且外部组件多,需要技术来隔离。Java的解决方案是:

  • 调整模块功能,减少纯粹合并字段的外部依赖,尽量让每个模块的功能原子化,这样外部依赖更少,可测性更高。
  • 使用ioc技术来方便注入mock和stub,主要工具是spring
  • 使用mockito技术来进行外部依赖的mock

4.3 Controller测试

Controller的主要功能是合并各个ao的输出,并且为每个页面专属服务,所以代码变动多,外部依赖丰富,是一个不太推荐使用单元测试的模块。当然,硬测也是可以的,需要框架提供模拟http请求,以及外部依赖mock的技术了,可是写起来依然是很麻烦,成效比太低。

5 总结

单元测试是重构代码和快速迭代开发的基石,在一个大型的程序中,没有单元测试实在是难以想象。当然,土豪可以堆钱请人来保证每次新功能发布前都来一发回归测试,毕竟有钱任性。

参考资料:

相关文章