Objective-C:写一份可测试的代码
APP重构之路(一) 网络请求框架
APP重构之路(二) Model的设计
APP重构之路(三) 引入单元测试
- Objective-C:写一份可测试的代码
前言
单元测试由程序员编写,最终又服务于程序员,但是在面对编写时复杂而繁琐的依赖注入、IoC,不禁让人思考这能否有必要。所以本文会讨论如何高效地编写一份具备可测试性代码的同时,保持代码的整洁与可了解性。
在这篇文章中我会用 OCMock + XCTest 作为基本的测试框架,假如你没有这方面的知识可以先提前理解,但我也会在对应模版代码中增加注释,方便大家了解。
善使用依赖注入
难以测试的设计 1
试想一下,我们正在开发一个自动驾驶的汽车,我们希望在早上能够定时启动我们的汽车,在中午时能够提前为我们开启空调,而在晚上能够提前打开收音机播放路况信息。这时我们就需要一个方法来返回当前时间对应的字符串如“早上”、“中午”、“晚上”,那我们就很容易写出如下代码:
- (NSString *)getCurrentTime{ NSDate *time = [NSDate date]; NSCalendar *calendar = [NSCalendar currentCalendar]; NSDateComponents *components = [calendar components:NSCalendarUnitHour fromDate:time]; NSInteger hour = [components hour]; if (hour >= 0 && hour < 6) { return @"Night"; } else if (hour >= 6 && hour < 12) { return @"Morning"; } else if (hour >= 12 && hour < 13) { return @"Noon"; } else if (hour >= 13 && hour < 18) { return @"Afternoon"; } return @"Evening";}
这段代码获取当前的系统时间,随后返回对应的字符串值,看起来并没有什么问题,于是我们对这段代码开始编写单元测试:
- (void)testGetCurrentTime{ AClassNeedToTest *testClass = [AClassNeedToTest new]; /* 在这里便无法继续编写测试代码 由于‘time’是在方法内初始化的,所以我没有办法去模拟系统时间的变化 导致我没有办法测试'getCurrentTime'这个方法的一律输出 */}
问题出在哪?
- 这段代码将对象的初始化与逻辑混合在了一起,导致了我们的单元测试变得无法进行
- 同时导致判断的逻辑无法被重使用
- 违背了单一职责准则
- 可能在正式环境中由于各种问题(如系统权限等等)导致出现错误
- 假如在内部创立的是如数据库等庞大的系统,则会拖慢测试速度
可测试可扩展的设计 1
最方便的方法就是让外部交给方法time
,而不是自己去创造。
- (NSString *)getCurrentTimeForDate:(NSDate *)date{ NSCalendar *calendar = [NSCalendar currentCalendar]; NSDateComponents *components = [calendar components:NSCalendarUnitHour fromDate:date]; NSInteger hour = [components hour]; if (hour >= 0 && hour < 6) { return @"Night"; } else if (hour >= 6 && hour < 12) { return @"Morning"; } else if (hour >= 12 && hour < 13) { return @"Noon"; } else if (hour >= 13 && hour < 18) { return @"Afternoon"; } return @"Evening";}
这时我们的测试代码将会是这样:
- (void)testGetCurrentTime{ AClassNeedToTest *testClass = [AClassNeedToTest new]; NSDate *dayTime = [NSDate dateWithTimeIntervalSince1970:60 * 60 * 9]; NSDate *noonTime = [NSDate dateWithTimeIntervalSince1970:60 * 60 * 12]; NSDate *eveningTime = [NSDate dateWithTimeIntervalSince1970:60 * 60 * 19]; // 更多测试使用例... XCTAssertEqual(@"Morning", [testClass getCurrentTimeForDate:dayTime]); XCTAssertEqual(@"Noon", [testClass getCurrentTimeForDate:noonTime]); XCTAssertEqual(@"Evening", [testClass getCurrentTimeForDate:eveningTime]); // 更多测试..}
现在代码从测试性来看就十分方便测试了,只要要模拟不同的时间并传入到方法中即可以测试对应输出能否正确。另外我们也把这个判断逻辑抽离出来,在其余地方我们也可以复使用。
难以测试的设计 2
我们继续开发我们的自动驾驶汽车,这时我们需要一个发动机,所以我们编写以下代码来组装我们的汽车:
- (void)buildCarWithFile:(File *)file{ Engine *engine = [[Engine alloc] initWithFile:file]; self.engine = engine; // build the car}
这个方法的设计上我们用了依赖注入,只需在测试的时候传入不同的file即可以测试到不同的轮胎和发动机了,我们的单元测试会是这个样子:
- (void)testBuildCar{ // 模拟一个文件,并设置对应的配置 id mockFile = OCMClassMock([File class]); mockFile.cofig = @"new Tides and a powerful engine"; Car *car = [Car new]; [car buildCarWithFile:mockFile]; // 接下来测试能否正确组装了车子 // ... // 现在要测试假如发动机不符合规格的时候是否组装成功 // 但是'Engine'只懂得造一个符合规格的发动机 // 测试无法继续进行了}
问题出在哪?
- 汽车需要的是发动机,但是传入的却是一个文件
- 尽管看起来是使用了依赖注入,但是却又在方法内部创立另少量对象
- 测试的时候也需要传递文件,会拖慢测试
可测试可扩展的设计 2
不要让你的汽车知道该怎样制造发动机,这不是他的职责。
- (void)buildCarWithEngine:(Engine *)engine{ self.engine = engine; // build the car}
这时你的测试代码会是这样:
- (void)testBuildCar{ // 模拟一个粗制滥造的引擎 id mockBadEngine = OCMClassMock([Engine class]); mockBadEngine.power = 0; Car *car = [Car new]; [car buildCarWithEngine:mockBadEngine]; // 测试使用不符合规格的发动机能否能够组装成功}
在方法移除了其余对象的构造后,能够简单的进行单元测试,所以在设计时要考虑依赖注入应该注入什么,你的方法真正需要的是什么。谨记在单元测试中“单元”两字,这意味着你应该能够在不干涉其余板块的情况下进行测试。
停下来,思考一下
依赖对象向上传递问题
在测试使用例1中,我们把time
的设置抽离了,但是在他的上一级,他也会遇到同样的问题,那我们应该继续抽离构建方法吗?显然不是,这样只是将初始化放到更高、更笼统的层次而已,并没有处理问题,还白白添加了调使用栈,让代码难以了解。
那我们应该怎样样解决这个问题呢?是应该用控制反转(IoC)吗?但真的值得为了测试去将整个原有的框架整体重构,并用各种繁琐的协议与代理商来完成吗?
我的建议是,不使用。这些问题我会选择用 swizzling 来处理,利使用runtime将对应方法进行替换。
既然可以替换方法,为什么还要用依赖注入?
依赖注入的关键点是可测试性与代码的维护性,按道理来说所有方法都能够swizzling,但不到不得已的点也不会轻易用。
依赖注入破坏封装性问题
针对这个问题,我会在测试板块中增加一个xxxx + UnitTest.h
的分类,这个分类文件只会被对应的测试代码引使用,里面包含了我在这个板块中所有应该和不应该暴露给外部的接口,甚至还有我想要测试的私有方法,通过这个方法就能够维持封装性与测试性的良好平衡。
另外可以对测试的粒度进行调整,过小的粒度会导致过多的接口暴露,在测试中没有必要去把所有的方法都测试完成,真正的单元测试在我看来是应该测试一个类,要确保一个类暴露出来的接口能够胜任它的工作,而不是其内在所有方法都要测试一边。
遵循最少知识准则
最少知识准则形容了一种保持代码低耦合的准则,具体来说就是对象应该尽可能避免调使用由另一个方法返回的对象的方法。打个比如:人可以开车,但是不应该直接指挥车轮滚动,而是应该由发动机去指挥。
难以测试的设计
还是我们的自动驾驶汽车,这次我们想训练一个智能的AI来驾驶车辆,所以我们写出了以下的代码:
- (void)trainDriveCar:(AIDriver *)driver{ for (Wheel *wheel in driver.car.wheels) { [wheel run]; }}
这段代码尽管违背了最少知识准则,但是看起来还是可以测试的,所以我们写出了这样的测试代码:
- (void)testAIDriver{ TestClass *testClass = [TestClass new]; // 模拟一个智能AI,并模拟它的汽车与汽车的轮子 id mockDriver = OCMClassMock([AIDriver class]); id mockCar = OCMClassMock([Car class]); id mockWheel = OCMClassMock([Wheel class]); OCMStub([mockDriver car]).andReturn(mockCar); OCMStub([mockCar wheels]).andReturn(@[mockWheel, mockWheel, mockWheel, mockWheel]); // do some test... [testClass trainDriveCar:mockDriver];}
问题出在哪里?
Car
和Wheel
状态的变化会使方法的结果难以确定- 脆弱的测试,任何对
Car
或者者Wheel
的修改都会破坏所有的测试使用例 - 复杂而且不必要,真正需要进行交互的仅仅是
AIDriver
而已 - 不能重使用
- 假如后来修改成我们的车子只要要三个轮子就能跑,那样会修改大量散落的代码
可测试可扩展的设计
在弄清楚我们需要交互的对象后,根据最少知识准则,我们可以进行如下修改:
- (void)trainDriveCar:(AIDriver *)driver{ [driver driveCar];}
而driveCar
方法则交由Driver内部实现,Car要怎样跑也交给Car内部来实现,他们对外暴露的仅仅只是一个操作的接口。这样我们即可以写出健壮的单元测试:
- (void)testAIDriver{ TestClass *testClass = [TestClass new]; // 模拟一个智能AI,并模拟它的汽车与汽车的轮子 id mockDriver = OCMClassMock([AIDriver class]); // do some test... [testClass trainDriveCar:mockDriver];}
等一下,这可能不是一个坏设计
等等,我在编写RAC代码时候经常会这样写:
[[[[client logInUser] flattenMap:^(User *user) { // Return a signal that loads cached messages for the user. return [client loadCachedMessagesForUser:user]; }] flattenMap:^(NSArray *messages) { // Return a signal that fetches any remaining messages. return [client fetchMessagesAfterMessage:messages.lastObject]; }] subscribeNext:^(NSArray *newMessages) { NSLog(@"New messages: %@", newMessages); } completed:^{ NSLog(@"Fetched all messages."); }];
这样我也是一个错误的设计吗?
当然不是,在我看来最少知识模式仅仅适使用于面向对象编程,由于它是利使用封装来把代码变得更好了解,违背了最少知识意味着这个方法的封装需要的不是它参数所要求的东西,那就意味了代码更难了解,而且其中状态的变化也变得不可控。
反观函数式编程,他原本就是无状态的函数,所以我们不使用担心在调使用时它的状态会被其余东西影响,只需数据是不可变的,那么即可以对它随心所欲的调使用,而且这样可读性也会高很多。
所以在用最少知识准则进行设计时需要先思考清楚这些点:
- 最少知识准则是为了确保方法不被可变的状态所影响
- 对于不可变的数据,最少知识准则并不适使用
警惕单例
在项目中我们可能有数十个单例,他们为我们提供各种简便的方法,但在测试时,他们可能成为我们的阻碍。
在我之前的文章就阐述过单例模式在测试上的问题:因为单例的全局性,他会使得单元测试不再“单元”,每一次测试的变化都会导致下一个测试产生无法意料的结果。
难以测试的设计
继续回到我们的自动驾驶汽车,这时我们想要我们的汽车能够连接上WiFi,所以我们构造了一个网络监视器来监听WiFi的连接状态:
@interface CarWiFiMonitor: NSObject+ (instancetype)sharedMonitor;@property (strong) CarWiFi *currentWiFi;@property (assign) CarWiFiStatus WiFiStatus;@end
通过构造这样一个单例,我们的汽车就能够获取网络的状态,并开始下载音乐操作:
- (void)downloadMusic{ if ([CarWiFiMonitor sharedMonitor].WiFiStatus == CarWiFiStatusConnected) { // download the music }}
而后我们针对下载音乐这个方法进行测试:
- (void)testDownloadMusic{ Car *testCar = [Car new]; // 模拟一个单例,并模拟状态为已连接 id mockMonitor = OCMClassMock([CarWiFiMonitor class]); OCMStub([mockMonitor WiFiStatus]).andReturn(CarWiFiStatusConnected); // 测试在已连接状态下是否下载成功 [testCar downloadMusic]; // 测试失败了 // 由于mockMonitor跟在'downloadMusic'中用的'[CarWiFiMonitor sharedInstance]'没有任何关系 // 并没有办法去模拟成功状态}
问题出在哪里?
- 我们生成的模拟对象没有替换一个单例
- 全局状态的不可控性,如在连接网络进行单元测试与不连接网络进行单元测试的结果完全不同
可测试但不是那么好的设计
既然单例没有办法替换,那我们就创造条件来替换他,利使用分类,我们可以创造一个可测试的分类:
CarWiFiMonitor + UnitTest.h
@interface CarWiFiMonitor (UnitTest)+ (instancetype)createMockMonitor;+ (instancetype)createPartialMockMonitor:(CarWiFiMonitor *)obj;+ (void)releaseMockMonitor;@end
CarWiFiMonitor + UnitTest.m
static CarWiFiMonitor *mockMonitor = nil;@implementation CarWiFiMonitor (UnitTest)#pragma clang diagnostic push#pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation"/** 让dataManager不论在哪里(测试使用例中和测试方法中)都返回我们的mock对象,用category重写sharedManage让它返回我们的mock对象 @return mockDataManager */+ (instancetype)sharedMonitor{ if (mockMonitor) { return mockMonitor; } static CarWiFiMonitor *sharedMonitor = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sharedMonitor = [[CarWiFiMonitor alloc] init]; }); return sharedMonitor;}#pragma clang diagnostic pop+ (instancetype)createMockMonitor{ mockMonitor = OCMClassMock([CarWiFiMonitor class]); return mockMonitor;}+ (instancetype)createPartialMockMonitor:(CarWiFiMonitor *)obj{ mockMonitor = OCMPartialMock(obj); return mockMonitor;}+ (void)releaseMockMonitor{ mockMonitor = nil;}
这样我们即可以在setup
与tearDown
方法中创立和释放我们的模拟单例:
- (void)setUp { [super setUp]; // 每个测试方法开始时都会调使用setup self.mockMonitor = [CarWiFiMonitor createMockMonitor];}- (void)tearDown { // 每个测试方法结束后都会调使用teardown [CarWiFiMoitor releaseMockMonitor]; [super tearDown];}
那样我们即可以用我们的模拟单例来进行测试了
- (void)testDownloadMusic{ Car *testCar = [Car new]; OCMStub([self.mockMonitor WiFiStatus]).andReturn(CarWiFiStatusConnected); [testCar downloadMusic]; // test ...}
我个人认为这不是一个很好的设计,我们项目中可能有数十个相似的单例,每一个都要这样做一个测试分类的工作量很大。另外模拟一个单例意味着我们要将整个单例的行为完全模仿,这意味着我们必需理解整个单例的工作模式,仔细阅读它的每一行代码,确保我们能够真实的展现这个单例的工作,否则我们的测试就仅仅是我们的臆想,并没有任何意义,这就意味着更大的工作量,我们更可能在不知不觉间模拟了一头怪兽。
但是对于这类全局状态,我们没有更好的方法对它进行测试,我们所能做到的只能是尽量减少它们出现的次数。
什么时候单例是一个好的设计?
假如数据是单向传输的话,单例会是一个好的设计。比方我们的行车日志就是一个好的单例模式,由于我们只会往行车日志进行记录,而不会从中读取任何东西,我们的汽车也不会由于我们开启或者者关闭了行车日志记录就发生任何变化,那么我们就能够简单的测试我们的上报系统,不使用担心行车日志单例会破坏我们的单元测试。
总结
其实在整体设计下来,似乎我们没有作出太多的修改,我们尽可能避免在OC上进行困难的IoC的同时,通过依赖注入与重新思考我们的代码设计来让我们的代码具备更好的可测试性。
所以可测试的代码并不意味着难以了解,有时候我们有一个误区:“我肯定要把代码拆分得琐碎不堪这样它们才是可以测试的“,其实并不然,一份好的代码并不是只循序一个准则的,可测试是有机会跟架构清晰共存的。
固然,设计这样一份可测试、容易维护、松耦合的代码会花掉我们大量精力,我们需要遵循不同的设计准则,但是软件设计素来不是一门可以拍脑袋就确定的学识,所以这一份可测试的代码不仅仅是为了测试,更是为了可了解性与可扩展性。
Reference
- 设计模式之禅
- Is testable code better code?
- Unit Tests, How to Write Testable Code and Why it Matters
- Writing Testable Code
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » Objective-C:写一份可测试的代码