Objective-C:寫一份可測試的代碼


前言

單元測試由程序員編寫,最終又服務於程序員,可是在面對編寫時複雜而繁瑣的依賴注入、IoC,不由讓人思考這是否有必要。因此本文會探討如何高效地編寫一份具備可測試性代碼的同時,保持代碼的整潔與可理解性。github

在這篇文章中我會使用 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'這個方法的所有輸出 */
}
複製代碼

問題出在哪?xcode

  • 這段代碼將對象的初始化與邏輯混合在了一塊兒,致使了咱們的單元測試變得沒法進行
  • 同時致使判斷的邏輯沒法被重用
  • 違反了單一職責原則
  • 可能在正式環境中由於各類問題(如系統權限等等)致使出現錯誤
  • 若是在內部建立的是如數據庫等龐大的系統,則會拖慢測試速度

可測試可擴展的設計 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

相關文章
相關標籤/搜索