Objective-C:写一份可测试的代码

4,417 阅读14分钟

前言

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

问题出在哪里?

  • CarWheel状态的变化会使方法的结果难以确定
  • 脆弱的测试,任何对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;
}

这样我们就可以在setuptearDown方法中创建和释放我们的模拟单例:

- (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