单元测试,一个不断被强调,又不断被人忽略的话题,想从屌丝程序员晋级成高级工程师,单元测试,可以说是必不可少的技能。如何编写合适的测试用例?何时该进行单元测试?单元测试所体现的价值究竟是什么?可以说,有很多实际的困扰阻碍着一批人,使得这些人被卡在了单元测试的门外,万事起步难,而当你真正的理解了一件事情的意图,就能很容易的从各个方面入手了。
本篇就我这些年来撰写测试的经验,结合Objective-C这门语言,总结出一些我认为可能对入门者很有帮助的方法,希望能让更多人进入到单元测试这个沉默的世界,使用它,并爱上它。少年,拿起你手中的XCode,去征服它吧!
单元测试的一般动机
为什么要写单元测试?做任何一件事情我们至少要清楚它的动机,否则做了也没太大意义,更别说去做好它。写单元测试也一样,并不是心血来潮了,就开始写测试用例,如果想让一件事情能够持之以恒,那至少要保证它的动机在我们目前所认知的范围内能不被撼动。以下是我总结出来的一些动机,欢迎大家品读。
被逼的
你妈逼你写单元测试了么?你妈可能真的没有,但你的领导却不一定。很多开发主管在听说了单元测试的种种传言后,便开始把单元测试纳入了绩效考核的范围,使得手下那些根本不懂单元测试的人抓耳捞腮,挤牙膏似得挤出了一大坨不伦不类的测试用例,之后不更新也不维护。
这种任务式的动机很难持久,而我之所以把它放在第一位,是因为这大概是大部分人最初接触单元测试的方式,起码我就是。虽然是被逼的,但至少让我们了解了怎样去使用一些单元测试框架(如庞大的[X]Unit家族),只是我们并知道怎么用合适,以及为什么要这么做。
赶时髦
当经过了被逼的阶段后,我们开始发现,世面上有点名气的开源项目中都会存在大量的单元测试,感觉自己不写点单元测试,这个feel提不上来啊。于是顶着满脑子的困惑,开始重抄旧刀,仿照着别人的命名与方式,给自己的应用写了些像样的测试用例。
这个feel并不是特别爽,因为自己很清楚,这些个测试用例写了与不写,似乎并没有什么两样。于是时间拉长点,过了那个初恋时的甜蜜期,进入平淡期时,很容易就放下了。因为并没有太多值得留恋的,这个动机本身就决定了不会太长久。虽然,最终我们还是和平分手了,但从这一段相处下来的时间里,我们意识到一个问题:单元测试是个好东西,只是现在的我还配不上它。
找个宿主程序
逼也逼过了,时髦你也赶了,似乎没有太多理由再让你去拿起单元测试了。但天无绝人之路,有一天你在为团队开发一些中间组件时,由于它没有界面,没有任何人机交互方式,没办法去验证你写的代码是否正确啊。这下不好办了,你又不想写一个程序低效的通过人工交互的方式去验证,那时候你就想,作为一个老字号码农,可不能出了些低级Bug丢了这张老脸啊,没办法,你毅然的接过XUnit
,这一次你知道要用它来干嘛了。
复合后的你们,相处得应该还是比较愉快的,因为这次你终于觉得不是为了写测试而写测试了,而是它真的很有用,它为你挽回了很多面子,让你的老脸能继续发热、发光。
求安慰
当你有了那样一次比较舒畅淋漓的经历后,你可能会开始反思,并试着寻找以前那些深藏在心中问题的答案。当你再次进入一个新项目时,你会觉得不写单元测试,感觉很多东西都隐隐靠不住,心里不踏实啊!对,你想它了,思念是有重量的,于是你开始自发性的完善测试,并开始尝试各种场景下测试用例的写法,你越来越了解它,最终,你们终于能愉快的在一起了。
单元测试的价值所在
说完了动机的故事,我们再谈谈单元测试它本身所具有的价值,虽说付出并不一定需要得到回报,但对任何人都毫无价值的事情,我们还是要坚决不做的。
减少低级错误
这一点是毋庸质疑的,测试所存在的最主要价值就是帮我们解决错误,单元测试也是这样。当我们在对自己的代码进行测试时,能很容易的就排除掉一些非常低级的错误,起码我们能够保证,在一些正常的情况下,代码是可以正常工作的。
当经历的语言和平台越来越多,很多平台相关的特性有时候并不是靠感觉就能拿得准的,比如你并不清楚NSString
对象的equalTo
和equalToString
这两个方法执行效果是否相同,那么你就有必要对使用到的代码进行测试去验证下,避免出现人为意识造成的低级Bug。
减少调试时间
可以说,在开发中我们有大部分的时间可能都是出于调试状态,减少调试时间,自然也就提高了产出率,而单元测试是否能提高产出率一直也是有点争议,不过它的确能够有效的减少调试时间。
在一个应用中,并不是所有需要调试的代码都在程序的入口点,所以,当我们需要调试时,会花费一些额外的时间来触发调试的代码。单元测试就能很好的解决这个问题,我们针对需要调试的代码,构建相关测试上下文,配合IDE,能方便快速的进行反复模拟、测试。
描述代码行为
很多书上都会说,代码就是最好的文档(当然是写得比较好的代码),注释需要能够精简,否则大片的注释会影响阅读。这点我是非常赞同的,而单元测试,作为代码的一等公民,我觉得它能更好的描述代码的行为。在撰写单元测试时,我们基本上都是假定某个方法,在某个特定的环境中,能够有预期的表现。如果这样的测试足够完善,那么,当我们去看别人测试时,就能很清楚他提供的方法是为了适应怎样的场景,能够更好的理解设计者的意图。
可维护性增强
当一个项目中单元测试的覆盖率很可观,后期在对代码进行修改时,能够很容易就知道是否破坏了老的业务逻辑,这样大大的降低了回归出错的可能性。当我们从测试那获得一个Bug时,可以通过测试用例去还原,当我们这个测试通过后,这个Bug也就解决了,而这个Bug Fix的测试用例也保证了以后这个Bug不会再次复现。
这会是一个很好的良性循环,我们的代码会越来越健壮,而我们可以把心思放在更多更有意义的事情上,比如重构。有了单元测试的保障,我们可以比较大胆的进行重构设计,当然,在重构时单元测试也会成为一种负担,我们可能需要同时重构单元测试,不过,相比于可靠性,这种负担还是非常值得去承受的。
改善设计
测试驱动设计,这在敏捷开发中是非常火热的名词,但我自身并不认为在一个较大型的项目中,能够完全按照这样的方式来驱动。虽然如此,但测试从一定的程度上能够改善设计,比如为了让一些类的某些行为中的细节得到充分测试(心里不再惴惴不安),我们就必须要对这些行为进行细分,于是我们开始提取方法,构建测试用例。这样,我们方法的行为会越来越单一,而良好的类设计中,正是需要这样的方法设计。
测试用例的三步曲
如何比较好的来编写一个测试用例,对此,有很多不同的做法,而这也并没有一个标准,也不需要有一个标准。我们需要清楚一个测试用例存在的意义是什么,它是为了验证某个类的某个行为在某种上下文中能得到预期的结果,如果你的测试用例达到了这样的目的,那么如何写也都不算错。不过,为了能够统一单元测试的规范(这点在多人协同开发下非常重要),我们常常会把一个测试用例分为三个阶段:排列资源、执行行为、断言结果,一般我会习惯用Arrange
、Act
、Assert
来表示,也会有用Given
,When
,Then
来表示的,但意思都相同。
排列资源
排列资源,便是提供一切测试方法所需要的东西,而这些东西便称之为资源。这些资源包括:
- 方法的输入参数
- 方法所执行的特定上下文
这个阶段相当于准备阶段,一切都是为了这个用例中执行行为而作准备,如果没有任何需要准备的数据,这个阶段是可以被忽略的。
这里,我们以测试NSMutableDictionary
的setObject:forKey:
为示例,那么在排列资源阶段,我们的代码如下:
- (void)test_setObject$forKey {
// arrange
NSString *key = @"test_key";
NSString *value = @"test_value";
NSMutableDictionary *dic = [NSMutableDictionary new];
}
关于测试用例的命名,我比较推崇这样的写法:
test_测试方法签名_测试上下文
由于Objective-C的方法签名比较奇怪,为了可读性,我建议使用$
进行分割,比如这个示例中的test_setObject$forKey
,或者附带上下文的test_setObject$forKey_when_key_is_nil
。
执行行为
当准备阶段完毕后,便进入要测试行为的执行阶段,在这个阶段,我们会使用准备好的资源,并记录下行为的输出以供下个阶段使用。这里的行为输出不一定就是方法执行的返回值,很多时候我们要测试的方法并没有任何返回值,但一个方法执行后,总归会有一个预期的行为会发生,即便是空方法也是(什么都不会被改变),而这个预期行为便是测试行为的输出。
加入执行行为的代码:
- (void)test_setObject$forKey {
// arrange
NSString *key = @"test_key";
NSString *value = @"test_value";
NSMutableDictionary *dic = [NSMutableDictionary new];
// act
[dic setObject:value forKey:key];
}
断言结果
最后一步,也是最核心的一步,它决定着一个测试用例的成功与否,我们需要在这一步断言执行行为的输出是否达到预期。确定一个行为的输出,我们可能需要有多次断言,这里需要遵循一个原则:先执行的断言,不应该以后执行的断言成功为前提。以上原则很重要,这对快速排除Bug会很有帮助。现在,我们来看下针对NSMutableDictionary
的这个完整测试用例:
- (void)test_setObject$forKey {
// arrange
NSString *key = @"test_key";
NSString *value = @"test_value";
NSMutableDictionary *dic = [NSMutableDictionary new];
// act
[dic setObject:value forKey:key];
// assert
XCTAssertNotNil([dic objectForKey:key]);
XCTAssertEqual([dic objectForKey:key], value);
}
可以看到,最后我们是先断言是否为空,再断言是否相等,后者是在前者成功的前提下才可能不失败。如果颠倒顺序,就很难尽早的发现错误原因,我们应该下意识的将这种断言的依赖关系排列正确,就像我们在很多语言里使用try...catch
时,我们会排列好异常捕获的顺序。
做到真正的单元测试
不知道大家有没有认真想过,这种测试为什么要叫Unit Test?顾名思义,是针对Unit来进行测试,也就是针对基本单元进行测试。所以,要做到真正的单元测试,你需要保证你每个测试用例所针对的仅仅是一个基本单元,而不是一个有很多复杂依赖的综合行为。
关于行为测试
在面向对象的程序设计中,一般最基本的单元就是一个类的方法,所以在单元测试中,我们要面对的就是针对这些方法编写合适的测试用例。方法就是一个类的对外行为,针对方法的测试也可以看作是针对一个类的行为测试,在编写测试用例时,我们不应该考虑一个行为的中间产出,我们应该将关注点放在最终的执行结果上。
关于行为测试,目前已有一套相关的理论和相应的测试框架,可以参考objc.io上的这篇文章。
关于隔离依赖
前面也提到了,我们需要的是针对一个基本单元进行测试,这样的要求会促使我们改善设计。我们应该尽可能让类方法的职责单一,这会方便我们撰写测试用例。理想中,每个类都是独立的,但现实里,一个类很少会没有依赖关系,而在编写测试用例时,我们不应该将依赖的类行为纳入到该类的测试用例中,被依赖的类应该是经过了单独测试,我们需要假定它是完全合理正确的。
为了能够不受依赖类的实现影响,我们可以将依赖的行为抽象成接口,依赖类去实现这样一个接口,最终可以通过构造函数或者其他方式注入进来。这样我们通过单元测试,又将设计推导到了另一个高度:依赖于抽象而不是具体实现细节。通过接口隔离依赖后,在单元测试里,我们可以撰写一些用于测试的模拟实现,也就是我们实现这样一个接口,但只是为了测试某种行为去实现它,而这便是所谓的Mock。
手动实现一个个Mock是非常耗时的,为了测试不同行为,我们可能需要不同的Mock对象,幸好几乎每一平台的单元测试都会有相应的Mock框架,Objective-C也不例外,这里推荐使用OCMockito,官方示例也很有代表性:
// mock creation
NSMutableArray *mockArray = mock([NSMutableArray class]);
// using mock object
[mockArray addObject:@"one"];
[mockArray removeAllObjects];
// verification
[verify(mockArray) addObject:@"one"];
[verify(mockArray) removeAllObjects];
虽然这个Mock框架可以构建Class
级别的模拟对象,但,我们应该把这种Class
当作是其它平台语义中的抽象类。前面说过了,我们应该尽可能的依赖于抽象,而不是实现细节。
再谈接口模拟与集成测试
为什么我们需要通过模拟去测试类的行为?既然这个类有依赖,何不将他依赖的具体实现直接使用在测试用例里?这样单元测试和运行时效果还会更加接近。
相信很多人都有过上面这样的疑问,其实根本的原因还是很简单的:关注点更单一。怎样才能做好一件事情,那就是要足够的专注,任何所谓的成功都离不开专注。单元测试专注于一个单元的测试,而不是多个单元糅合在一起,这样才能保证变化点都集中在被测试的单元中,才能体现出更高的维护价值。
那么,当我们几乎将所有类的公开行为都进行了单元测试,这时候我们就应该去编写集成测试了,集成测试与单元测试的关注点不同,它关心的是实现类在特定场景下交互的最终结果,可以说集成测试会更加动态,它可以模拟很多业务场景,而单元测试相对比较静态,它只是用来验证某一个动作的正确性。
所以,在优良的测试项目中,单元测试会和集成测试分开,当然现实中并不一定会这么做。就比如我们测试REST API
时,单元测试应该会去模拟网络返回数据,而集成测试才会真实的发送网络请求,很多时候我们都直接使用了后者,这样做感觉很方便,而好坏留给大家自己去斟酌吧。
总而言之
经过漫长的岁月洗礼,你终会从一个讨厌单元测试的小伙子变成一个热爱它的大叔,这是一个大同的方向。单元测试的利弊需要你在不同的项目中反复斟酌,任何一门技术都是需要不断总结,从而能向更高的层次演化。从现在开始,让单元测试来帮你描述代码的行为,并保证它的健壮性,而不是人为去规避一些设计缺陷。
本篇文章并没提供很多实际场景的测试方式,但理解了这件事情的动机后,你便可以自己去处理各种细枝末节。任何测试方式,它们的中心思想也都是百变不离其宗,只是手段不同罢了。授人以鱼不如授人以渔,有了良好的基础思想,我相信通过强大的搜索引擎,你一定也可以在这个领域里找到一份属于自己的归属感。