前言
单元测试由程序员编写,最终又服务于程序员,但是在面对编写时复杂而繁琐的依赖注入、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