单元测试的理解

时间:2021-07-21 16:43:25

 首先声明以下大部分是摘录。

原则定的都很好,是不是真的能做到?一切看起来都很美,一切听起来都对,在做的时候是不是真的落实了?

先来讲一个单元测试的故事

单元测试写出来容易跑过难!而且跑不过的原因还不是你的开发代码逻辑错了,而是测试环境/数据出问题。要测试,一定要有数据,这个数据的构建,完全不是我们所想象的那么简单。以我们项目里的积分系统为例,假设一个简单的需求:博客被点赞,博客的作者应该获得一定积分,该积分数量是由点赞人目前所有的可用币转换而得来的。要准备的数据就有:博客一篇,要有作者,作者已有积分;点赞人一名,有一定数量可用币。如果只是这样,还可以接受,但其实下面会有一堆的问题:

 

  • 作者的积分从哪里来?我们的开发代码,出于封装的考虑,用户的积分是只读的,你单元测试怎么设这个值?
  • 要么写代码,模拟作者通过其他行为(发布文章回答问题等)获得积分,这将开启新一轮噩梦;
  • 如果用Mock或者反射强行设置,事实上省略了作者获得积分的历史,所以用户“积分历史”为null,之后对其“加积分”时,就会报异常。
  • 更坑的是,你以为你什么都处理好了的时候,你突然悲哀的发现,这个博客得首先“被发布”,而博客一经发布,其作者就获得了一定数量的积分,所以你以前设置的积分又变了!
  • ……
  • 点赞人的可用币,同样可能遇到类似的问题。可用币怎么设置,设置之后会不会在跑测试时被意外更改?
  • 点赞的行为,被封装成一个方法,运行这个方法,会检查点赞人之前是否已经对该文章点过赞,所以还应该有一个“点赞历史记录”,哪怕是空的,都得new一个,否则就空异常
  • ……

 

反正当时是写得我直接摔了鼠标!写得憋屈啊!而且我还是完全隔绝了数据库的,真不知道那些要从数据库里取数据来跑单元测试的,是怎么做的?这时候我一下子就明白了,实际工作中加班赶进度,一个接一个的填坑,连重构的时间都没有,怎么可能还挤得出时间来写单元测试?就算开始雄心勃勃的写了,随着系统日益复杂,维护单元测试的成本也与日俱增,甚至复杂度更甚开发,所以放弃也就成了绝大多数项目的唯一选择。

 这个故事听起来就是一个从入门到放弃的例子。有很多的细节点都值得深思。

  1. 如何对某些模块独立测试,屏蔽相关项?
  2. 数据库操作怎么屏蔽?
  3. 变更需求测试代码也变更,造成大量工作量

 所以,今天来讲讲单元测试的相关基础知识及原则,自己对于单元测试的理解。

 

单元测试是什么?

单元测试是针对软件的最小模块进行正确性检验的测试工作。

什么是测试用例?

A test case has components that describes an input, action or event and an expected response, to determine if a feature of an application is working correctly.

哪些代码需要做单元测试?

所有的代码都需要单元测试.

所有公共(public)的代码都需要单元测试.

单元测试是程序员还是测试负责?

单元测试是由程序员自己来完成,最终受益的也是程序员自己。可以这么说,程序员有责任编写功能代码,同时也就有责任为自己的代码编写单元测试。执行单元测试,就是为了证明这段代码的行为和我们期望的一致。

为什么要使用单元测试

单元测试会为我们的承诺做保证。编写单元测试就是用来验证这段代码的行为是否与我们期望的一致。有了单元测试,我们可以自信的交付自己的代码,而没有任何的后顾之忧。

  • 单元测试使工作完成的更轻松
  • 单元测试使你的设计更好
  • 大大减少花在调试上的时间
  • 能帮助你更好的理解代码

如果没有单元测试

  • 任何代码都是在假定其他代码是正确无误的情况下编写的。
  • 修改一处代码时无法得知会对其他代码产生怎样的影响。
  • 任何一处改动都需要进行功能级别的整体调试。

什么是Mock,Stub,Spy?

(sinon.js对以下概念的定义)

Spy的作用在于可以监视一个函数的被调用情况。函数会被实际调用,Spy相当于给函数加了一层wrapper,可以记录函数被调用了几次,每次的参数是什么,每次返回结果是什么,出了什么异常。

Stub的作用是在测试中遇到这样的情形:测试函数f1,f1依赖于函数f2,根据最小化测试粒度的原则,测试f1时不要带入f2的测试,那么我们要确保f2的输出是正确的,所以直接让f2返回正确值的方法就叫stub。测试时,stub的方法是不会被具体执行的。

Mock是对于一个对象的监视,并且需要定义对这个对象的具体期待(verify),然后验证这些期待是否和测试数据一致。

在sinon.js等测试工具中,mock对象是不会被真正执行的。spy是真正执行的。

在其他一些测试工具中,对以上概念有所变化和混淆。例如mockito中,mock对象后,具体方法也可以定义返回值,作用等同于stub。

 

 单元测试的原则

1、每次只对一个对象进行UT测试(unit-test one object at a time)。这样能使你尽快发现问题,而不被各个对象之间的复杂关系所迷惑。

2、给测试方法起个好名字(choose meaningful test method names)。应该是用形如testXXXYYY(),这样的格式来命名你的测试方法。前缀test是Junit查找测试方法的依据,XXX应该是你测试的方法名,YYY应该是你测试的状态。当然如果你只有一种状态需要测试可以直接命名为testXXX()。

3、明确写出出错原因(explain the failure reason in assert calls)。在使用assertTrue,assertFalse,assertNotNull,assertNull方法时,应该将可能的错误的描述字符串,以第一个参数传入相应的方法。这样你可以迅速的找出出错原因。

4、一个UT测试方法只应该测试一种情况(one unit test equals one testMethod)。一个方法中的多次测试,只会混乱你的测试目的。

5、测试任何可能的错误(test anything that could possibly fail)。你的测试代码不是为了证明你是对的,而是为了证明你没有错。因此对测试的范围要全面,比如边界值、正常值、错误值;对代码可能出现的问题要全面预测。

6、让你的测试帮助改善你的代码(let the test improve the code)。测试代码永远是我们代码的第一个用户,所以不仅让他帮组我们发现Bug,还要帮组我们改善我们的设计,就是有名的测试驱动开发(Test-Driven Development,TDD)。

7、一样的包,不同的位置(same package, separate directories)。测试的代码和被测试的代码应该放到不同的文件夹中,建议使用这种目录 src/java/代码 src/test/测试代码。这样可以让两份代码使用一样的包结构,但是放在不同的目录下。 

8、关于setup与teardown 

a) 不要用TestCase的构造函数初始化Fixture,而要用setUp()和tearDown()方法。

c) 当继承一个测试类时,记得调用父类的setUp()和tearDown()方法。 

9、不要在mock object中牵扯到业务逻辑(don’t write business logic in mock objects)。 

10、只对可能产生错误的地方进行测试(only test what can possibly break)。如:一个类中频繁改动的函数。对于那些仅仅只含有getter/setter的类,如果是由IDE(如Eclipse)产生的,则可不测;如果是人工写,那么最好测试一下。

11、尽量不要依赖或假定测试运行的顺序,因为JUnit利用Vector保存测试方法。所以不同的平台会按不同的顺序从Vector中取出测试方法。

12、避免编写有副作用的TestCase,你要确信保持你的测试方法之间是独立的。 

13、将测试代码和工作代码放在一起,一边同步编译和更新(使用Ant中有支持junit的task)。

14、确保测试与时间无关,不要依赖使用过期的数据进行测试。导致在随后的维护过程中很难重现测试。

15、如果你编写的软件面向国际市场,编写测试时要考虑国际化的因素。不要仅用母语的Locale进行测试。 

16、尽可能地利用JUnit提供地assert/fail方法以及异常处理的方法,可以使代码更为简洁。

17、测试要尽可能地小,执行速度快。

 

单元测试最佳实践

实践一: 三到五步

  • SetUp
  • 输入
  • 调用
  • 输出
  • TearDown

实践二: 运行快速

为什么?

单元测试运行很频繁,是辅助开发的,在开发过程中运行,如果慢影响很大

多快较好?

  • 单个测试小于200ms
  • 单个测试套件小于10s
  • 整个测试小于10分钟

实践三:一致性

任何时候同样的输入需要同样的结果

    Date date=new Date()
Random.next()

这样的代码都需要Mock掉,不然时间每次都不同,结果就会不一样。

实践四:原子性

** 所有的测试只有两种结果:成功和失败**
不能部分测试通过

实践五:单一职责

一个测试只验证一个行为

** 测试行为,不要测试方法 **

  • 一个方法,多个行为    ----->  多个测试
  • 一个行为,多个方法   -----   一个测试
    这里的一个行为,多个方法一般指这个方法调用private, protected, getters, setters
  • 多个Assert只有在测试同一个行为时可以接受

实践六:独立无耦合

单元测试之间无相互调用

  • 单元测试执行顺序无关
  • 不同的顺序无影响

单元测试之间不能共享状态

比如一个测试里设置了一个属性值,然后在另外一个测试里用,如果必须共享可以放到Setup里

实践七:隔离外部调用

  • 单元测试需要快速运行,且每次结果一致,所以需要隔离一切对外部的调用。
  • 不使用具体的其它真实类,就是不要new
  • 不读数据库
  • 不读网络
  • 不读外部文件
  • 适当时候可以构造一个相同的内部文件来Mock
  • 不依赖本地时间
  • 不依赖环境变量

实践八: 自描述

  • 单元测试是开发级文档
  • 单元测试是方法的描述

实践九: 单元测试逻辑

  • 单元测试必须容易读和理解的
  • 变量名,方法名,类名
  • 无条件语句,无Switch
    办法:分解if到多个测试,所有的输入都是已知的,所有的结果都是一定的(Mock)
  • 无循环语句
  • 无异常捕捉
    ** 测试预知的异常,用ExpectedException方法 **

实践十: 断言

  • 断言信息最好包含Business Information
  • 断言信息包含出错的具体信息如果失败
  • 适当时候可以封装自己的Assert
    比如:

    Assert.IsProgrammer(Jack)
    Return Jack. Cancooking() && Jack.CanCoding()

实践十一:产品代码

  • 产品代码无测试逻辑
    不能有:

  If(global.IsTest){…}

    • 测试代码和产品代码要分离
    • 不要在产品代码里有任何只供测试用的代码
    • 使用依赖注入