8

走出 iOS 单元测试的困境

 3 years ago
source link: https://xiangwangfeng.com/2016/10/17/%E8%B5%B0%E5%87%BA-iOS-%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95%E7%9A%84%E5%9B%B0%E5%A2%83/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

走出 iOS 单元测试的困境

17 Oct 2016

Unit test is like teenage sex: everyone talks about it, nobody really knows how to do it, everyone thinks everyone else is doing it, so everyone claims they are doing it。 (所以说,你们都没做过么?)

单元测试的困境

单元测试在实际工作无法推进的原因,无外乎以下两点:缺乏实施动力和缺少实战经验。

动力的缺乏往往是因为对事物的理解不够彻底。举例来说,我们从来不会怀疑写代码的 80/20 原则,即需要使用 80% 的时间思考,用 20% 的时间进行真正的编码工作,这一点对于绝大多数程序员来说都是不言而喻的真理。但是对于单元测试,哪怕是科班出身的程序员都会质疑:在开发如此紧张的状态下,真的有必要去投入很大一块精力去进行单元测试么,投入产出比真的有这么大么?

另一方面更加重要的原因是,即使是多年的开发工程师(尤其是前端开发)在国内这种环境熏陶下,极度缺乏单元测试经验,使得他们往往无法有效构建一个合理的单元测试环境,使得单元测试变成一种安慰剂,而不是真正起到它所应该起到的作用。试想一下,你学习 iOS,网上有各种各样的教程教你从入门到精通,从网络到动画,多维度多层次的教程帮你提升,而一到单元测试这一方向,就只有最初级的测试框架简单使用的教程,这种教程和真正工程需求相差甚远,基本等同于教你会数数(数学的原理!),然后丢给你一张考卷让你解微积分一样可笑。

解决动力困境

常见使用单元测试的错误动机无外乎:被逼的(主管要求),赶时髦(看起来很酷,我也用用)。主管要求,下派任务,被动执行,这种流程下必然造成单元测试模块代码混乱,以应付为主,写了一大堆看似丰富实则无用的测试代码。而赶时髦也是一样,在观摩过高手的第三方类库后,发现有大量单元测试,于是依葫芦画瓢在自己的 app 中添加相应的单元测试,这种做法在过了前期的蜜月期后往往后继无力,并不能真正领略单元测试的作用。

而解决动力困境的方法很简单:痛过

在网易的前五年时间里,我参与的都是些客户端的开发。虽说是客户端开发,我的工作任务往往偏向底层,比如构建网络库(徒手撸 HttpClient),构建图片处理算法库(tinyimage)等,较少涉及 UI 开发。理论上来说,在这期间我应该会更多感受到没有单元测试的痛,但实际上却并不是这样。一方面,来自上层业务的需求比较稳定,接口量少且稳定,导致在完成相应方法后基本能够稳定使用,无需多次迭代。另一方面,即使某个版本实现有问题,也能够即时得到组员反馈,无非是多一次修改而已。

而开始开发云信 iOS SDK 开发后,情况则变得不大一样。首先随着云信提供的功能越来越多,相应的接口越来越多,也越来越复杂,想要依靠手工测试覆盖绝大部分场景越来越困难,导致测试时间拉长,版本迭代效率下降。其次模块使用者从组员变成了客户,大量的小 patch 不仅浪费各自时间,同时也会使客户越来越不信任 SDK 提供方。在经历了几次小补丁版本发布的痛苦,开始决定使用单元测试将这些不利因素扼杀在摇篮里。

对我们而言,单元测试会带来如下好处

  • 避免低级错误

    这是单元测试最直接的作用,无论是新手还是老手,低级错误总是难以避免的,原因不一而足:因为疏忽造成的拼写错误,因为注意力不足造成调用的错误,因为对 API 望文生义的理解造成的方法调用错误等。细心和责任心可以大大减少低级错误的发生,但不能完全杜绝。而单元测试可以作为一个很好的补充。

  • 减少调试时间

    在缺少宿主程序的情况下,单元测试可以充当宿主程序。而即使存在宿主程序,单元测试相对于宿主程序也有着入口简单,方便执行的优点,不再需要通过复杂的流程才能够进行对应方法的测试,大大减少调试时间。

  • 增加可维护性

    这是我最推崇单元测试的原因,随着代码量膨胀,代码和代码之间的边界越来越模糊,新代码的加入对旧代码的影响并不是都能够通过逻辑推导获知。但对一个模块添加足够的单元测试后,新代码的加入可以在第一时间内测试完毕对旧逻辑的影响,增加整个模块的可维护性,减少这部分的测试工作,将更多的时间投入到更有意义的事情上去,如喜闻乐见的重构。

  • 帮助改善设计

    接上一条,重构是贯穿于项目工程的一件任务,随着时间推移,项目总归会慢慢产生各种技术债务,渐进式的重构是很好的还债手段。但是作为一个保守的工程师,重构带来的不稳定有时候是无法接受的痛:既不带来可见的程序性能提高,又”浪费”了时间,与 KPI 无益。而单元测试可以第一时间揭示重构带来的问题,让我们大胆地进行调整,改善既有设计,实现一个良好的循环。

听到这里是不是干劲满满?然而不得不指出的一点是,单元测试不是万能的,并不是像很多人想的那样(甚至很多专业测试人员也包括在内):有了单元测试,就可以不再需要其他测试了。单元测试聚焦的是一个模块单元的功能完整性和鲁棒性,但是模块间互动可能带来的问题并不属于单元测试的范畴(虽然说单元测试和集成测试的边界有时候会傻傻分不清楚),同时也有很大部分的界面测试和功能测试仍旧离不开测试工程。

解决经验困境

相对于动力困境,经验困境反倒更难以解决。最大原因是这方面的教程并不多,新手入手难度较大。懂得使用 XCTest 到能够构建一个完整的测试工程往往有着巨大的实践认知鸿沟。所以仅就这段时间使用 XCTest 的经验谈怎么合理地使用 XCTest 给一个完整项目做单元测试。

构造自己的单元测试基础类库

一个让人觉得匪夷所思的事情在于,当我们去创建我们的 iOS 工程时,我们会进行框架设计,模块划分,我们有专门的基础类库,网络层,持久化层,表现层等。尤其是基础类库,在大公司里面基本是代代相传,百用不爽。但到了单元测试这边,哪怕是大公司也是一穷二白,基本没有任何积累。而实际上通过构造更完善的测试用基础类库,能够使得单元测试事半功倍。

举例而言,在 iOS 常常会需要测试异步方法的正确性。我们常常会用到 ‘XCTest’ 的 ‘expectationForNotification’ 和 ‘waitForExpectationsWithTimeout’ 做异步等待。

一个简单的异步测试代码往往如下


[manager asyncDo:^(NSError *error) {
        XCTAssertNil(error);
        
        dispatch_async(dispatch_get_main_queue(), ^{
            [[NSNotificationCenter defaultCenter] postNotificationName:@"test_sync_do"
                                                                object:nil];
        });
    }];
    [self expectationForNotification:@"test_sync_do" object:nil handler:nil];
    [self waitForExpectationsWithTimeout:60 handler:nil];

我们需要针对每个测试用例都定义通知名和调用重复的几个方法,那么就可以使用宏定义进行简化。


#define NIM_TEST_NOTIFY_KEY (@"nim_test_notification")

#define NIM_TEST_WAIT_WITH_KEY(key)\
{\
[self expectationForNotification:(key) object:nil handler:nil];\
[self waitForExpectationsWithTimeout:60 handler:nil];\
}

#define NIM_TEST_NOTIFY_WITH_KEY(key)\
{\
dispatch_async(dispatch_get_main_queue(), ^{ \
[[NSNotificationCenter defaultCenter] postNotificationName:(key) object:nil];\
});\
}


#define NIM_TEST_WAIT       NIM_TEST_WAIT_WITH_KEY(NIM_TEST_NOTIFY_KEY)
#define NIM_TEST_NOTIFY     NIM_TEST_NOTIFY_WITH_KEY(NIM_TEST_NOTIFY_KEY)

这样在构造测试单元时也能够少写无用代码,减少无用功。同样的场景还包括一些没有 block 作为回调结果的方法。当测试这些方法的时候,我们往往需要通过和 RunLoop 协作以达到在特定时间段内检查异步结果的流程,同样也可以使用宏定义进行简化。

另外一些需要放入测试基础类库的还包括一些基础方法,如生成随机的图片,文件,视频,生成随机数据等基础方法,这些方法会随着单元测试的深入而慢慢丰富。

构造完善的测试环境

虽然说单元测试更多的聚焦于具体的某个模块单元的测试,但是实际生产环境中的所谓模块单元并没有那么简单,并不是所有的方法都可以通过简单的 allocinit 然后 execute 就可以达到测试模块单元。一个测试用例往往会依赖严重于当前环境。

举个🌰,一个测试 A 向 某个群 B 发消息是否成功的接口,往往依赖于

  • 当前系统中是否有 A 账号和 B 群
  • A 是否是 B 群成员
  • A 是否没有被禁言
  • 当前 B 群是否有效
  • 当前 A 是否已经登录

那么在这个进行这一个接口测试时,我们就需要保证以上条件全部为真,这也就是需要我们进行构造测试环境,并在测试结束后进行相应的清理。如测试踢人接口后,需要在结束时将对应成员拉回群里,以保证下次的单元测试能够正常进行。这是整个单元测试的重中之重,完成这一步构造和设计,后续工作将会轻松不少。

JUST DO IT

经过上面两步,已经可以基本搭建出一个测试工程了,接下来无非是一些搬砖的活,针对不同层次的单元模块添加相应的测试用例即可。纸上得来终觉浅,绝知此事要躬行:随着实践的深入,经验的也会越来越深,相应的测试用例也会越来越完善。

最后一步,将单元测试自动化,集成在 CI 流程中,在每次代码签入后能够自动执行相应的单元测试,以发挥其最大的效果。iOS 下的可选项并不多,一般而言使用 Jenkins 是个比较不错的选择。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK