你應該知道的單元測試

摘自http://www.51testing.com/html/75/n-3721875.html

單元測試,一個不斷被強調,又不斷被人忽略的話題,想從屌絲程序員晉級成高級工程師,單元測試,可以說是必不可少的技能。如何編寫合適的測試用例?何時該進行單元測試?單元測試所體現的價值究竟是什么?可以說,有很多實際的困擾阻礙著一批人,使得這些人被卡在了單元測試的門外,萬事起步難,而當你真正的理解了一件事情的意圖,就能很容易的從各個方面入手了。

本篇就我這些年來撰寫測試的經驗,結合Objective-C這門語言,總結出一些我認為可能對入門者很有幫助的方法,希望能讓更多人進入到單元測試這個沉默的世界,使用它,并愛上它。少年,拿起你手中的XCode,去征服它吧!

單元測試的一般動機

為什么要寫單元測試?做任何一件事情我們至少要清楚它的動機,否則做了也沒太大意義,更別說去做好它。寫單元測試也一樣,并不是心血來潮了,就開始寫測試用例,如果想讓一件事情能夠持之以恒,那至少要保證它的動機在我們目前所認知的范圍內能不被撼動。以下是我總結出來的一些動機,歡迎大家品讀。

被逼的

你媽逼你寫單元測試了么?你媽可能真的沒有,但你的領導卻不一定。很多開發主管在聽說了單元測試的種種傳言后,便開始把單元測試納入了績效考核的范圍,使得手下那些根本不懂單元測試的人抓耳撈腮,擠牙膏似得擠出了一大坨不倫不類的測試用例,之后不更新也不維護。

這種任務式的動機很難持久,而我之所以把它放在第一位,是因為這大概是大部分人最初接觸單元測試的方式,起碼我就是。雖然是被逼的,但至少讓我們了解了怎樣去使用一些單元測試框架(如龐大的[X]Unit家族),只是我們并知道怎么用合適,以及為什么要這么做。

趕時髦

當經過了被逼的階段后,我們開始發現,世面上有點名氣的開源項目中都會存在大量的單元測試,感覺自己不寫點單元測試,這個feel提不上來啊。于是頂著滿腦子的困惑,開始重抄舊刀,仿照著別人的命名與方式,給自己的應用寫了些像樣的測試用例。

這個feel并不是特別爽,因為自己很清楚,這些個測試用例寫了與不寫,似乎并沒有什么兩樣。于是時間拉長點,過了那個初戀時的甜蜜期,進入平淡期時,很容易就放下了。因為并沒有太多值得留戀的,這個動機本身就決定了不會太長久。雖然,最終我們還是和平分手了,但從這一段相處下來的時間里,我們意識到一個問題:單元測試是個好東西,只是現在的我還配不上它。

找個宿主程序

逼也逼過了,時髦你也趕了,似乎沒有太多理由再讓你去拿起單元測試了。但天無絕人之路,有一天你在為團隊開發一些中間組件時,由于它沒有界面,沒有任何人機交互方式,沒辦法去驗證你寫的代碼是否正確啊。這下不好辦了,你又不想寫一個程序低效的通過人工交互的方式去驗證,那時候你就想,作為一個老字號碼農,可不能出了些低級Bug丟了這張老臉啊,沒辦法,你毅然的接過XUnit,這一次你知道要用它來干嘛了。

復合后的你們,相處得應該還是比較愉快的,因為這次你終于覺得不是為了寫測試而寫測試了,而是它真的很有用,它為你挽回了很多面子,讓你的老臉能繼續發熱、發光。

求安慰

當你有了那樣一次比較舒暢淋漓的經歷后,你可能會開始反思,并試著尋找以前那些深藏在心中問題的答案。當你再次進入一個新項目時,你會覺得不寫單元測試,感覺很多東西都隱隱靠不住,心里不踏實啊!對,你想它了,思念是有重量的,于是你開始自發性的完善測試,并開始嘗試各種場景下測試用例的寫法,你越來越了解它,最終,你們終于能愉快的在一起了。

單元測試的價值所在

說完了動機的故事,我們再談談單元測試它本身所具有的價值,雖說付出并不一定需要得到回報,但對任何人都毫無價值的事情,我們還是要堅決不做的。

減少低級錯誤

這一點是毋庸質疑的,測試所存在的最主要價值就是幫我們解決錯誤,單元測試也是這樣。當我們在對自己的代碼進行測試時,能很容易的就排除掉一些非常低級的錯誤,起碼我們能夠保證,在一些正常的情況下,代碼是可以正常工作的。

當經歷的語言和平臺越來越多,很多平臺相關的特性有時候并不是靠感覺就能拿得準的,比如你并不清楚NSString對象的equalTo和equalToString這兩個方法執行效果是否相同,那么你就有必要對使用到的代碼進行測試去驗證下,避免出現人為意識造成的低級Bug。

減少調試時間

可以說,在開發中我們有大部分的時間可能都是出于調試狀態,減少調試時間,自然也就提高了產出率,而單元測試是否能提高產出率一直也是有點爭議,不過它的確能夠有效的減少調試時間。

在一個應用中,并不是所有需要調試的代碼都在程序的入口點,所以,當我們需要調試時,會花費一些額外的時間來觸發調試的代碼。單元測試就能很好的解決這個問題,我們針對需要調試的代碼,構建相關測試上下文,配合IDE,能方便快速的進行反復模擬、測試。

描述代碼行為

很多書上都會說,代碼就是最好的文檔(當然是寫得比較好的代碼),注釋需要能夠精簡,否則大片的注釋會影響閱讀。這點我是非常贊同的,而單元測試,作為代碼的一等公民,我覺得它能更好的描述代碼的行為。在撰寫單元測試時,我們基本上都是假定某個方法,在某個特定的環境中,能夠有預期的表現。如果這樣的測試足夠完善,那么,當我們去看別人測試時,就能很清楚他提供的方法是為了適應怎樣的場景,能夠更好的理解設計者的意圖。

可維護性增強

當一個項目中單元測試的覆蓋率很可觀,后期在對代碼進行修改時,能夠很容易就知道是否破壞了老的業務邏輯,這樣大大的降低了回歸出錯的可能性。當我們從測試那獲得一個Bug時,可以通過測試用例去還原,當我們這個測試通過后,這個Bug也就解決了,而這個Bug Fix的測試用例也保證了以后這個Bug不會再次復現。

這會是一個很好的良性循環,我們的代碼會越來越健壯,而我們可以把心思放在更多更有意義的事情上,比如重構。有了單元測試的保障,我們可以比較大膽的進行重構設計,當然,在重構時單元測試也會成為一種負擔,我們可能需要同時重構單元測試,不過,相比于可靠性,這種負擔還是非常值得去承受的。

改善設計

測試驅動設計,這在敏捷開發中是非常火熱的名詞,但我自身并不認為在一個較大型的項目中,能夠完全按照這樣的方式來驅動。雖然如此,但測試從一定的程度上能夠改善設計,比如為了讓一些類的某些行為中的細節得到充分測試(心里不再惴惴不安),我們就必須要對這些行為進行細分,于是我們開始提取方法,構建測試用例。這樣,我們方法的行為會越來越單一,而良好的類設計中,正是需要這樣的方法設計。

測試用例的三步曲

如何比較好的來編寫一個測試用例,對此,有很多不同的做法,而這也并沒有一個標準,也不需要有一個標準。我們需要清楚一個測試用例存在的意義是什么,它是為了驗證某個類的某個行為在某種上下文中能得到預期的結果,如果你的測試用例達到了這樣的目的,那么如何寫也都不算錯。不過,為了能夠統一單元測試的規范(這點在多人協同開發下非常重要),我們常常會把一個測試用例分為三個階段:排列資源、執行行為、斷言結果,一般我會習慣用Arrange、Act、Assert來表示,也會有用Given,When,Then來表示的,但意思都相同。

排列資源

排列資源,便是提供一切測試方法所需要的東西,而這些東西便稱之為資源。這些資源包括:

方法的輸入參數

方法所執行的特定上下文

這個階段相當于準備階段,一切都是為了這個用例中執行行為而作準備,如果沒有任何需要準備的數據,這個階段是可以被忽略的。

這里,我們以測試NSMutableDictionary的setObject:forKey:為示例,那么在排列資源階段,我們的代碼如下:

- (void)test_setObject$forKey {

// arrange

NSString *key = @"test_key";

NSString *value = @"test_value";

NSMutableDictionary *dic = [NSMutableDictionary new];

}

關于測試用例的命名,我比較推崇這樣的寫法:

test_測試方法簽名_測試上下文

由于Objective-C的方法簽名比較奇怪,為了可讀性,我建議使用$進行分割,比如這個示例中的test_setObject$forKey,或者附帶上下文的test_setObject$forKey_when_key_is_nil。

執行行為

當準備階段完畢后,便進入要測試行為的執行階段,在這個階段,我們會使用準備好的資源,并記錄下行為的輸出以供下個階段使用。這里的行為輸出不一定就是方法執行的返回值,很多時候我們要測試的方法并沒有任何返回值,但一個方法執行后,總歸會有一個預期的行為會發生,即便是空方法也是(什么都不會被改變),而這個預期行為便是測試行為的輸出。

加入執行行為的代碼:

- (void)test_setObject$forKey {

// arrange

NSString *key = @"test_key";

NSString *value = @"test_value";

NSMutableDictionary *dic = [NSMutableDictionary new];

// act

[dic setObject:value forKey:key];

}

斷言結果

最后一步,也是最核心的一步,它決定著一個測試用例的成功與否,我們需要在這一步斷言執行行為的輸出是否達到預期。確定一個行為的輸出,我們可能需要有多次斷言,這里需要遵循一個原則:先執行的斷言,不應該以后執行的斷言成功為前提。以上原則很重要,這對快速排除Bug會很有幫助。現在,我們來看下針對NSMutableDictionary的這個完整測試用例:

- (void)test_setObject$forKey {

// arrange

NSString *key = @"test_key";

NSString *value = @"test_value";

NSMutableDictionary *dic = [NSMutableDictionary new];

// act

[dic setObject:value forKey:key];

// assert

XCTAssertNotNil([dic objectForKey:key]);

XCTAssertEqual([dic objectForKey:key], value);

}

可以看到,最后我們是先斷言是否為空,再斷言是否相等,后者是在前者成功的前提下才可能不失敗。如果顛倒順序,就很難盡早的發現錯誤原因,我們應該下意識的將這種斷言的依賴關系排列正確,就像我們在很多語言里使用try...catch時,我們會排列好異常捕獲的順序。

做到真正的單元測試

不知道大家有沒有認真想過,這種測試為什么要叫Unit Test?顧名思義,是針對Unit來進行測試,也就是針對基本單元進行測試。所以,要做到真正的單元測試,你需要保證你每個測試用例所針對的僅僅是一個基本單元,而不是一個有很多復雜依賴的綜合行為。

關于行為測試

在面向對象的程序設計中,一般最基本的單元就是一個類的方法,所以在單元測試中,我們要面對的就是針對這些方法編寫合適的測試用例。方法就是一個類的對外行為,針對方法的測試也可以看作是針對一個類的行為測試,在編寫測試用例時,我們不應該考慮一個行為的中間產出,我們應該將關注點放在最終的執行結果上。

關于行為測試,目前已有一套相關的理論和相應的測試框架,可以參考objc.io上的這篇文章

關于隔離依賴

前面也提到了,我們需要的是針對一個基本單元進行測試,這樣的要求會促使我們改善設計。我們應該盡可能讓類方法的職責單一,這會方便我們撰寫測試用例。理想中,每個類都是獨立的,但現實里,一個類很少會沒有依賴關系,而在編寫測試用例時,我們不應該將依賴的類行為納入到該類的測試用例中,被依賴的類應該是經過了單獨測試,我們需要假定它是完全合理正確的。

為了能夠不受依賴類的實現影響,我們可以將依賴的行為抽象成接口,依賴類去實現這樣一個接口,最終可以通過構造函數或者其他方式注入進來。這樣我們通過單元測試,又將設計推導到了另一個高度:依賴于抽象而不是具體實現細節。通過接口隔離依賴后,在單元測試里,我們可以撰寫一些用于測試的模擬實現,也就是我們實現這樣一個接口,但只是為了測試某種行為去實現它,而這便是所謂的Mock。

手動實現一個個Mock是非常耗時的,為了測試不同行為,我們可能需要不同的Mock對象,幸好幾乎每一平臺的單元測試都會有相應的Mock框架,Objective-C也不例外,這里推薦使用OCMockito,官方示例也很有代表性:

// mock creation

NSMutableArray *mockArray = mock([NSMutableArray class]);

// using mock object

[mockArray addObject:@"one"];

[mockArray removeAllObjects];

// verification

[verify(mockArray) addObject:@"one"];

[verify(mockArray) removeAllObjects];

雖然這個Mock框架可以構建Class級別的模擬對象,但,我們應該把這種Class當作是其它平臺語義中的抽象類。前面說過了,我們應該盡可能的依賴于抽象,而不是實現細節。

再談接口模擬與集成測試

為什么我們需要通過模擬去測試類的行為?既然這個類有依賴,何不將他依賴的具體實現直接使用在測試用例里?這樣單元測試和運行時效果還會更加接近。

相信很多人都有過上面這樣的疑問,其實根本的原因還是很簡單的:關注點更單一。怎樣才能做好一件事情,那就是要足夠的專注,任何所謂的成功都離不開專注。單元測試專注于一個單元的測試,而不是多個單元糅合在一起,這樣才能保證變化點都集中在被測試的單元中,才能體現出更高的維護價值。

那么,當我們幾乎將所有類的公開行為都進行了單元測試,這時候我們就應該去編寫集成測試了,集成測試與單元測試的關注點不同,它關心的是實現類在特定場景下交互的最終結果,可以說集成測試會更加動態,它可以模擬很多業務場景,而單元測試相對比較靜態,它只是用來驗證某一個動作的正確性。

所以,在優良的測試項目中,單元測試會和集成測試分開,當然現實中并不一定會這么做。就比如我們測試REST API時,單元測試應該會去模擬網絡返回數據,而集成測試才會真實的發送網絡請求,很多時候我們都直接使用了后者,這樣做感覺很方便,而好壞留給大家自己去斟酌吧。

總而言之

經過漫長的歲月洗禮,你終會從一個討厭單元測試的小伙子變成一個熱愛它的大叔,這是一個大同的方向。單元測試的利弊需要你在不同的項目中反復斟酌,任何一門技術都是需要不斷總結,從而能向更高的層次演化。從現在開始,讓單元測試來幫你描述代碼的行為,并保證它的健壯性,而不是人為去規避一些設計缺陷。

本篇文章并沒提供很多實際場景的測試方式,但理解了這件事情的動機后,你便可以自己去處理各種細枝末節。任何測試方式,它們的中心思想也都是百變不離其宗,只是手段不同罷了。授人以魚不如授人以漁,有了良好的基礎思想,我相信通過強大的搜索引擎,你一定也可以在這個領域里找到一份屬于自己的歸屬感。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容