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

杜瑋發表於2018-08-30

前言

單元測試由程式設計師編寫,最終又服務於程式設計師,但是在面對編寫時複雜而繁瑣的依賴注入、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

相關文章