单元测试的困境
单元测试在实际工作无法推进的原因,无外乎以下两点:缺乏实施动力和缺少实战经验。
动力的缺乏往往是因为对事物的理解不够彻底。举例来说,我们从来不会怀疑写代码的 80/20 原则,即需要使用 80% 的时间思考,用 20% 的时间进行真正的编码工作,这一点对于绝大多数程序员来说都是不言而喻的真理。但是对于单元测试,哪怕是科班出身的程序员都会质疑:在开发如此紧张的状态下,真的有必要去投入很大一块精力去进行单元测试么,投入产出比真的有这么大么?
另一方面更加重要的原因是,即使是多年的开发工程师(尤其是前端开发)在国内这种环境熏陶下,极度缺乏单元测试经验,使得他们往往无法有效构建一个合理的单元测试环境,使得单元测试变成一种安慰剂,而不是真正起到它所应该起到的作用。试想一下,你学习 iOS,网上有各种各样的教程教你从入门到精通,从网络到动画,多维度多层次的教程帮你提升,而一到单元测试这一方向,就只有最初级的测试框架简单使用的教程,这种教程和真正工程需求相差甚远,基本等同于教你会数数(数学的原理!),然后丢给你一张考卷让你解微积分一样可笑。
解决动力困境
常见使用单元测试的错误动机无外乎:被逼的(主管要求),赶时髦(看起来很酷,我也用用)。主管要求,下派任务,被动执行,这种流程下必然造成单元测试模块代码混乱,以应付为主,写了一大堆看似丰富实则无用的测试代码。而赶时髦也是一样,在观摩过高手的第三方类库后,发现有大量单元测试,于是依葫芦画瓢在自己的 app 中添加相应的单元测试,这种做法在过了前期的蜜月期后往往后继无力,并不能真正领略单元测试的作用。
而解决动力困境的方法很简单:痛过。
在网易的前五年时间里,我参与的都是些客户端的开发。虽说是客户端开发,我的工作任务往往偏向底层,比如构建网络库(徒手撸 HttpClient),构建图片处理算法库(tinyimage)等,较少涉及 UI 开发。理论上来说,在这期间我应该会更多感受到没有单元测试的痛,但实际上却并不是这样。一方面,来自上层业务的需求比较稳定,接口量少且稳定,导致在完成相应方法后基本能够稳定使用,无需多次迭代。另一方面,即使某个版本实现有问题,也能够即时得到组员反馈,无非是多一次修改而已。
而开始开发云信 iOS SDK 开发后,情况则变得不大一样。首先随着云信提供的功能越来越多,相应的接口越来越多,也越来越复杂,想要依靠手工测试覆盖绝大部分场景越来越困难,导致测试时间拉长,版本迭代效率下降。其次模块使用者从组员变成了客户,大量的小 patch 不仅浪费各自时间,同时也会使客户越来越不信任 SDK 提供方。在经历了几次小补丁版本发布的痛苦,开始决定使用单元测试将这些不利因素扼杀在摇篮里。
对我们而言,单元测试会带来如下好处
· 避免低级错误
这是单元测试最直接的作用,无论是新手还是老手,低级错误总是难以避免的,原因不一而足:因为疏忽造成的拼写错误,因为注意力不足造成调用的错误,因为对 API 望文生义的理解造成的方法调用错误等。细心和责任心可以大大减少低级错误的发生,但不能完全杜绝。而单元测试可以作为一个很好的补充。
· 减少调试时间
在缺少宿主程序的情况下,单元测试可以充当宿主程序。而即使存在宿主程序,单元测试相对于宿主程序也有着入口简单,方便执行的优点,不再需要通过复杂的流程才能够进行对应方法的测试,大大减少调试时间。
· 增加可维护性
这是我最推崇单元测试的原因,随着代码量膨胀,代码和代码之间的边界越来越模糊,新代码的加入对旧代码的影响并不是都能够通过逻辑推导获知。但对一个模块添加足够的单元测试后,新代码的加入可以在第一时间内测试完毕对旧逻辑的影响,增加整个模块的可维护性,减少这部分的测试工作,将更多的时间投入到更有意义的事情上去,如喜闻乐见的重构。
· 帮助改善设计
接上一条,重构是贯穿于项目工程的一件任务,随着时间推移,项目总归会慢慢产生各种技术债务,渐进式的重构是很好的还债手段。但是作为一个保守的工程师,重构带来的不稳定有时候是无法接受的痛:既不带来可见的程序性能提高,又”浪费”了时间,与 KPI 无益。而单元测试可以第一时间揭示重构带来的问题,让我们大胆地进行调整,改善既有设计,实现一个良好的循环。
听到这里是不是干劲满满?然而不得不指出的一点是,单元测试不是万能的,并不是像很多人想的那样(甚至很多专业测试人员也包括在内):有了单元测试,就可以不再需要其他测试了。单元测试聚焦的是一个模块单元的功能完整性和鲁棒性,但是模块间互动可能带来的问题并不属于单元测试的范畴(虽然说单元测试和集成测试的边界有时候会傻傻分不清楚),同时也有很大部分的界面测试和功能测试仍旧离不开测试工程。
解决经验困境
相对于动力困境,经验困境反倒更难以解决。最大原因是这方面的教程并不多,新手入手难度较大。懂得使用 XCTest 到能够构建一个完整的测试工程往往有着巨大的实践认知鸿沟。所以仅就这段时间使用 XCTest 的经验谈怎么合理地使用 XCTest 给一个完整项目做单元测试。
构造自己的单元测试基础类库
一个让人觉得匪夷所思的事情在于,当我们去创建我们的 iOS 工程时,我们会进行框架设计,模块划分,我们有专门的基础类库,网络层,持久化层,表现层等。尤其是基础类库,在大公司里面基本是代代相传,百用不爽。但到了单元测试这边,哪怕是大公司也是一穷二白,基本没有任何积累。而实际上通过构造更完善的测试用基础类库,能够使得单元测试事半功倍。
举例而言,在 iOS 常常会需要测试异步方法的正确性。我们常常会用到 ‘XCTest’ 的 ‘expectationForNotification’ 和 ‘waitForExpectationsWithTimeout’ 做异步等待。
一个简单的异步测试代码往往如下
我们需要针对每个测试用例都定义通知名和调用重复的几个方法,那么就可以使用宏定义进行简化。
这样在构造测试单元时也能够少写无用代码,减少无用功。同样的场景还包括一些没有 block 作为回调结果的方法。当测试这些方法的时候,我们往往需要通过和 RunLoop 协作以达到在特定时间段内检查异步结果的流程,同样也可以使用宏定义进行简化。
另外一些需要放入测试基础类库的还包括一些基础方法,如生成随机的图片,文件,视频,生成随机数据等基础方法,这些方法会随着单元测试的深入而慢慢丰富。
构造完善的测试环境
虽然说单元测试更多的聚焦于具体的某个模块单元的测试,但是实际生产环境中的所谓模块单元并没有那么简单,并不是所有的方法都可以通过简单的 alloc,init 然后 execute 就可以达到测试模块单元。一个测试用例往往会依赖严重于当前环境。
举个栗子,一个测试 A 向 某个群 B 发消息是否成功的接口,往往依赖于
· 当前系统中是否有 A 账号和 B 群
· A 是否是 B 群成员
· A 是否没有被禁言
· 当前 B 群是否有效
· 当前 A 是否已经登录
那么在这个进行这一个接口测试时,我们就需要保证以上条件全部为真,这也就是需要我们进行构造测试环境,并在测试结束后进行相应的清理。如测试踢人接口后,需要在结束时将对应成员拉回群里,以保证下次的单元测试能够正常进行。这是整个单元测试的重中之重,完成这一步构造和设计,后续工作将会轻松不少。
JUST DO IT
经过上面两步,已经可以基本搭建出一个测试工程了,接下来无非是一些搬砖的活,针对不同层次的单元模块添加相应的测试用例即可。纸上得来终觉浅,绝知此事要躬行:随着实践的深入,经验的也会越来越深,相应的测试用例也会越来越完善。
CI
最后一步,将单元测试自动化,集成在 CI 流程中,在每次代码签入后能够自动执行相应的单元测试,以发挥其最大的效果。iOS 下的可选项并不多,一般而言使用 Jenkins 是个比较不错的选择。