单元测试的理解
首先声明以下大部分是摘录。
原则定的都很好,是不是真的能做到?一切看起来都很美,一切听起来都对,在做的时候是不是真的落实了?
先来讲一个单元测试的故事
单元测试写出来容易跑过难!而且跑不过的原因还不是你的开发代码逻辑错了,而是测试环境/数据出问题。要测试,一定要有数据,这个数据的构建,完全不是我们所想象的那么简单。以我们项目里的积分系统为例,假设一个简单的需求:博客被点赞,博客的作者应该获得一定积分,该积分数量是由点赞人目前所有的可用币转换而得来的。要准备的数据就有:博客一篇,要有作者,作者已有积分;点赞人一名,有一定数量可用币。如果只是这样,还可以接受,但其实下面会有一堆的问题:
- 作者的积分从哪里来?我们的开发代码,出于封装的考虑,用户的积分是只读的,你单元测试怎么设这个值?
- 要么写代码,模拟作者通过其他行为(发布文章回答问题等)获得积分,这将开启新一轮噩梦;
- 如果用Mock或者反射强行设置,事实上省略了作者获得积分的历史,所以用户“积分历史”为null,之后对其“加积分”时,就会报异常。
- 更坑的是,你以为你什么都处理好了的时候,你突然悲哀的发现,这个博客得首先“被发布”,而博客一经发布,其作者就获得了一定数量的积分,所以你以前设置的积分又变了!
- ……
- 点赞人的可用币,同样可能遇到类似的问题。可用币怎么设置,设置之后会不会在跑测试时被意外更改?
- 点赞的行为,被封装成一个方法,运行这个方法,会检查点赞人之前是否已经对该文章点过赞,所以还应该有一个“点赞历史记录”,哪怕是空的,都得new一个,否则就空异常
- ……
反正当时是写得我直接摔了鼠标!写得憋屈啊!而且我还是完全隔绝了数据库的,真不知道那些要从数据库里取数据来跑单元测试的,是怎么做的?这时候我一下子就明白了,实际工作中加班赶进度,一个接一个的填坑,连重构的时间都没有,怎么可能还挤得出时间来写单元测试?就算开始雄心勃勃的写了,随着系统日益复杂,维护单元测试的成本也与日俱增,甚至复杂度更甚开发,所以放弃也就成了绝大多数项目的唯一选择。
这个故事听起来就是一个从入门到放弃的例子。有很多的细节点都值得深思。
- 如何对某些模块独立测试,屏蔽相关项?
- 数据库操作怎么屏蔽?
- 变更需求测试代码也变更,造成大量工作量
所以,今天来讲讲单元测试的相关基础知识及原则,自己对于单元测试的理解。
单元测试是什么?
单元测试是针对软件的最小模块进行正确性检验的测试工作。
什么是测试用例?
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){…}
- 测试代码和产品代码要分离
- 不要在产品代码里有任何只供测试用的代码
- 使用依赖注入