转载自你应该知道的单元测试
一、单元测试
单元测试,一个不断被强调,又不断被人忽略的话题,想从屌丝程序员晋级成高级工程师,单元测试,可以说是必不可少的技能。如何编写合适的测试用例?何时该进行单元测试?单元测试所体现的价值究竟是什么?可以说,有很多实际的困扰阻碍着一批人,使得这些人被卡在了单元测试的门外,万事起步难,而当你真正的理解了一件事情的意图,就能很容易的从各个方面入手了。
二、单元测试的价值
减少低级错误
这一点是毋庸质疑的,测试所存在的最主要价值就是帮我们解决错误,单元测试也是这样。当我们在对自己的代码进行测试时,能很容易的就排除掉一些非常低级的错误,起码我们能够保证,在一些正常的情况下,代码是可以正常工作的。
当经历的语言和平台越来越多,很多平台相关的特性有时候并不是靠感觉就能拿得准的,比如你并不清楚NSString对象的equalTo和equalToString这两个方法执行效果是否相同,那么你就有必要对使用到的代码进行测试去验证下,避免出现人为意识造成的低级Bug。
减少调试时间
可以说,在开发中我们有大部分的时间可能都是出于调试状态,减少调试时间,自然也就提高了产出率,而单元测试是否能提高产出率一直也是有点争议,不过它的确能够有效的减少调试时间。
在一个应用中,并不是所有需要调试的代码都在程序的入口点,所以,当我们需要调试时,会花费一些额外的时间来触发调试的代码。单元测试就能很好的解决这个问题,我们针对需要调试的代码,构建相关测试上下文,配合IDE,能方便快速的进行反复模拟、测试。
描述代码行为
很多书上都会说,代码就是最好的文档(当然是写得比较好的代码),注释需要能够精简,否则大片的注释会影响阅读。这点我是非常赞同的,而单元测试,作为代码的一等公民,我觉得它能更好的描述代码的行为。在撰写单元测试时,我们基本上都是假定某个方法,在某个特定的环境中,能够有预期的表现。如果这样的测试足够完善,那么,当我们去看别人测试时,就能很清楚他提供的方法是为了适应怎样的场景,能够更好的理解设计者的意图。
可维护性增强
当一个项目中单元测试的覆盖率很可观,后期在对代码进行修改时,能够很容易就知道是否破坏了老的业务逻辑,这样大大的降低了回归出错的可能性。当我们从测试那获得一个Bug时,可以通过测试用例去还原,当我们这个测试通过后,这个Bug也就解决了,而这个Bug Fix的测试用例也保证了以后这个Bug不会再次复现。
这会是一个很好的良性循环,我们的代码会越来越健壮,而我们可以把心思放在更多更有意义的事情上,比如重构。有了单元测试的保障,我们可以比较大胆的进行重构设计,当然,在重构时单元测试也会成为一种负担,我们可能需要同时重构单元测试,不过,相比于可靠性,这种负担还是非常值得去承受的。
改善设计
测试驱动设计,这在敏捷开发中是非常火热的名词,但我自身并不认为在一个较大型的项目中,能够完全按照这样的方式来驱动。虽然如此,但测试从一定的程度上能够改善设计,比如为了让一些类的某些行为中的细节得到充分测试(心里不再惴惴不安),我们就必须要对这些行为进行细分,于是我们开始提取方法,构建测试用例。这样,我们方法的行为会越来越单一,而良好的类设计中,正是需要这样的方法设计。
三、测试用例三部曲
如何比较好的来编写一个测试用例,对此,有很多不同的做法,而这也并没有一个标准,也不需要有一个标准。我们需要清楚一个测试用例存在的意义是什么,它是为了验证某个类的某个行为在某种上下文中能得到预期的结果,如果你的测试用例达到了这样的目的,那么如何写也都不算错。不过,为了能够统一单元测试的规范(这点在多人协同开发下非常重要),我们常常会把一个测试用例分为三个阶段:排列资源、执行行为、断言结果,一般我会习惯用Arrange、Act、Assert来表示,也会有用Given,When,Then来表示的,但意思都相同。
排列资源
排列资源,便是提供一切测试方法所需要的东西,而这些东西便称之为资源。这些资源包括:
- 1.方法输入的参数
- 2.方法所执行的特定上下文
这个阶段相当于准备阶段,一切都是为了这个用例中执行行为而准备,如果没有任何需要准备的数据,这个阶段是可以被忽略的。
这里我们以测试 NSMutableDictionary
的 dic 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来进行测试,也就是针对基本的单元进行测试。所以要做到真正的单元测试,你需要保证你每个测试用例所针对的仅仅是一个基本的单元,而不是很多复杂依赖的综合行为。
关于行为测试
在面向对象的程序设计中,一般最基本的单元就是一个类的方法,所以在单元测试中,我们要面对的就是针对这些方法编写合适的测试用例。方法就是一个类的对外行为,针对方法的测试也可以看做是针对一个类的行为测试,在编写测试用例时,我们不应该考虑一个行为的中间产出,我们应该将关注点放在最终的测试执行结果上。
关于行为测试,目前已经有一套相关的理论和相应的测试框架,可以参考 行为驱动开发
关于隔离依赖
前面也提到了,我们要的是针对一个基本单元的测试,这样的要求会促使我们改善设计。我们应该竟可能让类方法的职责单一,这会方便我们撰写测试用例。理想中,每个类都是独立的,但现实里,一个类很少会没有依赖关系,而在编写测试用例时,我们不应该将依赖的类行为纳入到该类的测试用例中,被依赖的类应该是经过了单独测试,我们需要假定它是完全合理正确的。
为了能够不受依赖类的实例影响,我们可以将依赖的行为抽象成接口,依赖类去实现这样一个接口,最终可以通过构造函数或者其他方式注入进来。我们通过单元测试,又将设计推导到了另一个高度:依赖抽象而不是实现具体细节。 通过接口隔离依赖后,在单元测试里,我们可以撰写一些用于测试的模拟实现,也就是我们实现这样一个接口,但只是为了测试某种行为去实现它,这便是所谓的 ** 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
时,单元测试应该回去模拟网络返回的数据,而集成测试才会真实的发送网络请求,很多时候我们都直接使用了后者,这样做感觉很方便,而好坏就留给大家自己去斟酌吧。
六、总而言之
经过漫长的岁月洗礼,你终会变成一个热爱它的猿。单元测试的利弊需要你在不同的项目中反复斟酌,任何一门技术都是需要不断总结,从而能向更高层次演化。从现在开始,让单元测试来帮你描述代码行为,并保证它的健壮性,而不是人为去规避一些设计缺陷。
本章并没有提供很多实际场景的测试方式,但理解了这件事情的动机后,便可以自己去处理各种细枝末节。任何测试方式,它们的中心思想也是万变不离其宗,只是手段不同罢了。授人以鱼不如授人以渔,有了良好的基础思想,我相信通过强大的搜索引擎,我们一定也可以在这个领域里找到一份属于自己的归属感。
单元测试时可能遇到的坑:找不到文件