单元测试,一个不断被强调,又不断被人忽略的话题。如何编写合适的测试用例?何时该进行单元测试?单元测试所体现的价值究竟是什么?可以说,有很多实际的困扰阻碍着一批人,使得这些人被卡在了单元测试的门外,万事起步难,而当你真正的理解了一件事情的意图,就能很容易的从各个方面入手了。单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
单元测试推荐使用框架 Quick
单元测试的价值
减少低级错误
这一点是毋庸质疑的,测试所存在的最主要价值就是帮我们解决错误,单元测试也是这样。当我们在对自己的代码进行测试时,能很容易的就排除掉一些非常低级的错误,起码我们能够保证,在一些正常的情况下,代码是可以正常工作的。
当经历的语言和平台越来越多,很多平台相关的特性有时候并不是靠感觉就能拿得准的,比如你并不清楚NSString对象的equalTo和equalToString这两个方法执行效果是否相同,那么你就有必要对使用到的代码进行测试去验证下,避免出现人为意识造成的低级Bug。
减少调试时间
可以说,在开发中我们有大部分的时间可能都是出于调试状态,减少调试时间,自然也就提高了产出率,而单元测试是否能提高产出率一直也是有点争议,不过它的确能够有效的减少调试时间。
在一个应用中,并不是所有需要调试的代码都在程序的入口点,所以,当我们需要调试时,会花费一些额外的时间来触发调试的代码。单元测试就能很好的解决这个问题,我们针对需要调试的代码,构建相关测试上下文,配合IDE,能方便快速的进行反复模拟、测试。
描述代码行为
很多书上都会说,代码就是最好的文档(当然是写得比较好的代码),注释需要能够精简,否则大片的注释会影响阅读。这点我是非常赞同的,而单元测试,作为代码的一等公民,我觉得它能更好的描述代码的行为。在撰写单元测试时,我们基本上都是假定某个方法,在某个特定的环境中,能够有预期的表现。如果这样的测试足够完善,那么,当我们去看别人测试时,就能很清楚他提供的方法是为了适应怎样的场景,能够更好的理解设计者的意图。
可维护性增强
当一个项目中单元测试的覆盖率很可观,后期在对代码进行修改时,能够很容易就知道是否破坏了老的业务逻辑,这样大大的降低了回归出错的可能性。当我们从测试那获得一个Bug时,可以通过测试用例去还原,当我们这个测试通过后,这个Bug也就解决了,而这个Bug Fix的测试用例也保证了以后这个Bug不会再次复现。
这会是一个很好的良性循环,我们的代码会越来越健壮,而我们可以把心思放在更多更有意义的事情上,比如重构。有了单元测试的保障,我们可以比较大胆的进行重构设计,当然,在重构时单元测试也会成为一种负担,我们可能需要同时重构单元测试,不过,相比于可靠性,这种负担还是非常值得去承受的。
改善设计
测试驱动设计,这在敏捷开发中是非常火热的名词,但我自身并不认为在一个较大型的项目中,能够完全按照这样的方式来驱动。虽然如此,但测试从一定的程度上能够改善设计,比如为了让一些类的某些行为中的细节得到充分测试(心里不再惴惴不安),我们就必须要对这些行为进行细分,于是我们开始提取方法,构建测试用例。这样,我们方法的行为会越来越单一,而良好的类设计中,正是需要这样的方法设计。
测试用例三部曲
如何比较好的来编写一个测试用例,对此,有很多不同的做法,而这也并没有一个标准,也不需要有一个标准。我们需要清楚一个测试用例存在的意义是什么,它是为了验证某个类的某个行为在某种上下文中能得到预期的结果,如果你的测试用例达到了这样的目的,那么如何写也都不算错。不过,为了能够统一单元测试的规范(这点在多人协同开发下非常重要),我们常常会把一个测试用例分为三个阶段:排列资源、执行行为、断言结果,一般我会习惯用Arrange、Act、Assert
来表示,也会有用Given,When,Then
来表示的,但意思都相同。
排列资源
排列资源,便是提供一切测试方法所需要的东西,而这些东西便称之为资源。这些资源包括:
-
方法输入的参数
-
方法所执行的特定上下文
这个阶段相当于准备阶段,一切都是为了这个用例中执行行为而准备,如果没有任何需要准备的数据,这个阶段是可以被忽略的。
这里我们以测试 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 时,我们会排列好异常捕获的顺序。
普通逻辑功能测试–XCTAssert
1 |
|
性能测试
性能测试主要使用 measureBlock 方法 ,用于测试一组方法的执行时间,通过设置baseline(基准)和stddev(标准偏差)来判断方法是否能通过性能测试。
1 |
|
设置时间耗时标准如下图:
我们来看看这几个参数都是啥意思:
-
Baseline 计算标准差的参考值
- MAX STDD 最大允许的标准差
- 底部点击1,2…10可以看到每次运行的结果。
点击Edit,我们点击Average的右边的Accept,来让本次运行的平均值设置为baseline,然后然后MAX STDD改为40%。再运行这个测试用例,你会发现测试成功了。
异步测试
异步测试的逻辑如下,首先定义一个或者多个XCTestExpectation,表示异步测试想要的结果。然后设置timeout,表示异步测试最多可以执行的时间。最后,在异步的代码完成的最后,调用fullfill来通知异步测试满足条件。具体案例如下:
1 |
|
Mock
为了能够不受依赖类的实例影响,我们可以将依赖的行为抽象成接口,依赖类去实现这样一个接口,最终可以通过构造函数或者其他方式注入进来。我们通过单元测试,又将设计推导到了另一个高度:依赖抽象而不是实现具体细节。 通过接口隔离依赖后,在单元测试里,我们可以撰写一些用于测试的模拟实现,也就是我们实现这样一个接口,但只是为了测试某种行为去实现它,这便是所谓的 ** Mock**。
手动实现一个个Mock是非常耗时的,为了测试不同的行为,我们可能需要不同的Mock对象,幸好几乎每一平台的单元测试都会有相应得Mock框架,Objective-C也不例外,推荐使用的两个框架:
1. OCMockito
2. OHHTTPStubs(专门用来mock网络请求数据),使用方法入下:
没有使用stub的请求:
1 |
|
使用stub拦截请求:
1 |
|
单元测试基本原则
做到真正的单元测试
不知道大家有没有认真想过,这种测试为什么要叫Unit Test?顾名思义,是针对Unit来进行测试,也就是针对基本的单元进行测试。所以要做到真正的单元测试,你需要保证你每个测试用例所针对的仅仅是一个基本的单元,而不是很多复杂依赖的综合行为。
关于行为测试
在面向对象的程序设计中,一般最基本的单元就是一个类的方法,所以在单元测试中,我们要面对的就是针对这些方法编写合适的测试用例。方法就是一个类的对外行为,针对方法的测试也可以看做是针对一个类的行为测试,在编写测试用例时,我们不应该考虑一个行为的中间产出,我们应该将关注点放在最终的测试执行结果上。
关于行为测试,目前已经有一套相关的理论和相应的测试框架,可以参考 行为驱动开发
关于隔离依赖
前面也提到了,我们要的是针对一个基本单元的测试,这样的要求会促使我们改善设计。我们应该竟可能让类方法的职责单一,这会方便我们撰写测试用例。理想中,每个类都是独立的,但现实里,一个类很少会没有依赖关系,而在编写测试用例时,我们不应该将依赖的类行为纳入到该类的测试用例中,被依赖的类应该是经过了单独测试,我们需要假定它是完全合理正确的。
接口模拟与集成测试
为什么我们需要通过模拟去测试类的行为?既然这个类有依赖,何不将他依赖的具体实现直接使用在测试用例里?这样单元测试和运行时效果还会更加接近。
相信很多人都有过上面这样的疑问,其实根本原因还是很简单的:关注点更单一。怎样才能做好一件事情,那就要足够的专注,任何所谓的成功都离不开专注。单元测试专注于一个单元的测试,它不是多个单元糅合在一起,这样才能保证变化点都集中在被测试的单元中,才能体现出更好的维护价值。 那么,当我们几乎将所有类的公开行为都进行了单元测试,这时候我们就应该去编写集成测试了,集成测试与单元测试的关注点不同,它关心的是实现类在特定场景下交互的最终结果,可以说集成测试会更加动态,它可以模拟很多业务场景,而单元测试相对比较静态,它只是用来验证一个动作的正确性。
所以,在优良的测试项目中,单元测试会和集成测试分开,当然现实中不一定会这么做。就比如我们测试 REST API 时,单元测试应该回去模拟网络返回的数据,而集成测试才会真实的发送网络请求,很多时候我们都直接使用了后者,这样做感觉很方便,而好坏就留给大家自己去斟酌吧。
单元的测试的基本实践
- 单元测试不依赖主工程
- 单元测依赖主工程