objc系列譯文(1.3):測試 View Controllers

發表於2014-05-30

我們不是信奉測試,但它應該幫助我們加快開發進度,並且讓事情變得更有趣。

讓事情保持簡單

測試簡單的事情很簡單,同樣,測試複雜的事會很複雜。就像我們在其他文章中指出的那樣,讓事情保持簡單小巧總是好的。除此之外,它還有利於我們測試。這是件雙贏的事。讓我們來看看測試驅動開發(簡稱 TDD),有些人喜歡它,有些人則不喜歡。我們在這裡不深入討論,只是如果用 TDD,你得在寫程式碼之前先寫好測試。如果有什麼疑問,可以去看看 Wikipedia 上的文章。同時,我們也認為重構和測試可以很好地結合在一起。

測試 UI 部分通常很麻煩,因為它們包含太多活動部件。通常,view controller 需要和大量的 model 和 view 類互動。為了使 view controller 便於測試,我們要讓任務儘量分離。

幸好,我們在更輕量的 view controller 這篇文章中的闡述的技術可以讓測試更加簡單。通常,如果你發現有些地方很難做測試,這就說明你的設計出了問題,你應該重構它。你可以重新參考更輕量的 view controller 這篇文章來獲得一些幫助。總的目標就是有清晰的關注點分離。每個類只做一件事,並且做好。這樣就可以讓你只測試這件事。

記住:測試越多,回報的增長趨勢越慢。首先你應該做簡單的測試。當你覺得滿意時,再加入更多複雜的測試。

Mocking

當你把一個整體拆分成小零件(即更小的類)時,我們可以在每個類中進行測試。但由於我們測試的類會和其他類互動,這裡我們用一個所謂的 mockstub 來繞開它。把 mock 物件看成是一個佔位符,我們測試的類會跟這個佔位符互動,而不是真正的那個物件。這樣,我們就可以針對性地測試,並且保證不依賴於應用程式的其他部分。

在示例程式中,我們有個包含陣列的 data source 需要測試。這個 data source 會在某個時候從 table view 中取出(dequeue)一個 cell。在測試過程中,還沒有 table view,但是我們傳遞一個 mock table view,這樣即使沒有 table view,也可以測試 data source,就像下面你即將看到的。起初可能有點難以理解,多看幾次後,你就能體會到它的強大和簡單。

Objective-C 中有個用來 mocking 的強大工具叫做 OCMock。它是一個非常成熟的專案,充分利用了 Objective-C 執行時強大的能力和靈活性。它使用了一些很酷的技巧,讓通過 mock 物件來測試變得更加有趣。

本文後面有 data source 測試的例子,它更加詳細地展示了這些技術如何工作在一起。

SenTestKit

我們將要使用的另一個工具是一個測試框架,開發者工具的一部分:Sente 的 SenTestingKit。這個上古神器從 1997 年起就伴隨在 Objective-C 開發者左右,比第一款 iPhone 釋出還早 10 年。現在,它已經整合到 Xcode 中了。SenTestingKit 會執行你的測試。通過 SenTestingKit,你將測試組織在類中。你需要給每一個你想測試的類建立一個測試類,類名以 Testing 結尾,它反應了這個類是幹什麼的。

這些測試類的方法會做具體的測試工作。方法名必須以 test 開頭來作為觸發一個測試執行的條件。還有特殊的 -setUp-tearDown 方法,你可以過載它們來設定各個測試。記住,你的測試類就是個類而已:只要對你有幫助,隨便在裡面加 properties 和輔助方法。

做測試時,為測試類建立基類是個不錯的模式。把通用的邏輯放到基類裡面,可以讓測試更簡單和集中。可以通過示例程式中的例子來看看這樣帶來的好處。我們沒有使用 Xcode 的測試模板,為了讓事情簡單有效,我們只建立了單獨的 .m 檔案。通過把類名改成以 Tests 結尾,類名可以反映出我們在對什麼做測試。

與 Xcode 整合

測試會被 build 成一個 bundle,其中包含一個動態庫和你選擇的資原始檔。如果你要測試某些資原始檔,你得把它們加到測試的 target 中,Xcode 就會將它們打包到一個 bundle 中。接著你可以通過 NSBundle 來定位這些資原始檔,示例專案實現了一個 -URLForResource:withExtension: 方法來方便的使用它。

Xcode 中的每個 scheme 定義了相應的測試 bundle 是哪個。通過 ⌘-R 執行程式,⌘-U 執行測試。

測試的執行依附於程式的執行,當程式執行時,測試 bundle 將被注入(injected)。測試時,你可能不想讓你的程式做太多的事,那樣會對測試造成干擾。可以把下面的程式碼加到 app delegate 中:

編輯 Scheme 給了你極大的靈活性。你可以在測試之前或之後執行指令碼,也可以有多個測試 bundle。這對大型專案來說很有用。最重要的是,可以開啟或關閉個別測試,這對除錯測試非常有用,只是要記得把它們重新全部開啟。

還要記住你可以為測試程式碼下斷點,當測試執行時,偵錯程式會在斷點處停下來。

測試 Data Source

好了,讓我們開始吧。我們已經通過拆分 view controller 讓測試工作變得更輕鬆了。現在我們要測試 ArrayDataSource。首先我們新建一個空的,基本的測試類。我們把介面和實現都放到一個檔案裡;也沒有哪個地方需要包含 @interface,放到一個檔案會顯得更加漂亮和整潔。

這個類沒做什麼事,只是展示了基本的設定。當我們執行這個測試時,-testNothing 方法將會執行。特別地,STAssert 巨集將會做瑣碎的檢查。注意,字首 ST 源自於 SenTestingKit。這些巨集和 Xcode 整合,會把失敗顯示到 Issues navigator 中。

第一個測試

我們現在把 testNothing 替換成一個簡單、真正的測試:

實踐 Mocking

接著,我們想測試 ArrayDataSource 實現的方法:

為此,我們建立一個測試方法:

首先,建立一個 data source:

注意,configureCellBlock 除了儲存物件以外什麼都沒做,這可以讓我們可以更簡單地測試它。

然後,我們為 table view 建立一個 mock 物件

Data source 將在傳進來的 table view 上呼叫 -dequeueReusableCellWithIdentifier:forIndexPath: 方法。我們將告訴 mock object 當它收到這個訊息時要做什麼。首先建立一個 cell,然後設定 mock

第一次看到它可能會覺得有點迷惑。我們在這裡所做的,是讓 mock 記錄特定的呼叫。Mock 不是一個真正的 table view;我們只是假裝它是。-expect 方法允許我們設定一個 mock,讓它知道當這個方法呼叫時要做什麼。

另外,-expect 方法也告訴 mock 這個呼叫必須發生。當我們稍後在 mock 上呼叫 -verify 時,如果那個方法沒有被呼叫過,測試就會失敗。相應地,-stub 方法也用來設定 mock 物件,但它不關心方法是否被呼叫過。

現在,我們要觸發程式碼執行。我們就呼叫我們希望測試的方法。

然後我們測試是否一切正常:

STAssert 巨集測試值的相等性。注意,前兩個測試,我們通過比較指標來完成;我們不想使用 -isEqual:。我們實際希望測試的是 resultcellconfiguredCell 都是同一個物件。第三個測試要用 -isEqual:,最後我們呼叫 mock 的 -verify 方法。

注意,在示例程式中,我們是這樣設定 mock 的:

這是我們測試基類中的一個方便的封裝,它會在測試最後自動呼叫 -verify 方法。

測試 UITableViewController

下面,我們轉向 PhotosViewController。它是個 UITableViewController 的子類,它使用了我們剛才測試過的 data source。View controller 剩下的程式碼已經相當簡單了。

我們想測試點選 cell 後把我們帶到詳情頁面,即一個 PhotoViewController 的例項被 push 到 navigation controller 裡面。我們再次使用 mocking 來讓測試儘可能不依賴於其他部分。

首先我們建立一個 UINavigationController 的 mock:

接下來,我們要使用 partial mocking。我們希望 PhotosViewController 例項的 navigationController 返回 mockNavController。我們不能直接設定 navigation controller,所以我們簡單地對 PhotosViewController 例項 stub 這個方法,讓它返回 mockNavController 就可以了。

現在,任何時候對 photosViewController 呼叫 -navigationController 方法,都會返回 mockNavController。這是個強大的技巧,OCMock 有這種本領。

現在,我們要告訴 navigation controller mock 我們呼叫的期望,即,一個 photo 不為 nil 的 detail view controller。

現在,我們觸發 view 載入,並且模擬一行被點選:

最後我們驗證 mocks 上期望的方法被呼叫過:

現在我們有了一個測試,用來測試和 navigation controller 的互動,以及正確 view controller 的建立。

又一次地,我們在示例程式中使用了便捷的方法:

於是,我們不需要記住呼叫 -verify

進一步探索

就像你從上面看到的那樣,partial mocking 非常強大。如果你看看 -[PhotosViewController setupTableView] 方法的原始碼,你就會看到它是如何從 app delegate 中取出 model 物件的。

上面的測試依賴於這行程式碼。打破這種依賴的一種方式是再次使用 partial mocking,讓 app delegate 返回預定義的資料,就像這樣:

現在,無論 [AppDelegate sharedDelegate].store 時候呼叫過,它也會返回 storeMock。可以把它發揮到極致。確保讓你的測試儘可能保持簡單,除非確實有複雜的需要。

牢記的事

Partial mocks 會修改 mocking 的物件,並且在 mocks 的生存期一直有效。你可以通過提前呼叫 [aMock stopMocking] 來停止這種行為。大多數時候,你希望 partial mock 在整個測試期間都保持有效。確保在測試方法最後放置 [aMock verify]。否則 ARC 會過早 dealloc 這個 mock。而且不管怎樣,你都希望加上 -verify

測試 NIB 載入

PhotoCell 設定在一個 NIB 中,我們可以寫一個簡單的測試來檢查 outlets 設定得是否正確。我們來回顧一下 PhotoCell 類:

我們的簡單測試的實現看上去是這樣:

非常基礎,但是它能工作。

值得一提的是,當有什麼發生變動時,測試和相應的類或 nib 需要同時更新。這是事實。你需要把它和 outlets 變化的可能性做權衡。如果你用了 .xib 檔案,你可能要注意了,這是經常發生的事。

關於 Class 和 Injection

我們已經從與 Xcode 整合得知,測試 bundle 會注入到應用程式中。省略注入的如何工作的細節(它本身是個巨大的話題),簡單地說:注入是把待注入的 bundle(我們的測試 bundle)中的 Objective-C 類新增到執行的應用程式中。這很好,因為這樣允許我們執行測試了。

還有一件事會很讓人迷惑,那就是如果我們同時把一個類新增到應用程式和測試 bundle中。如果在上面的示例程式中,(偶然)把 PhotoCell 類新增到測試 bundle 和應用程式,然後在測試 bundle 中呼叫 [PhotoCell class] 會返回一個不同的指標(你應用程式中的那個類)。於是我們的測試將會失敗:

再一次宣告:注入很複雜。你應該避免:不要把應用程式中的 .m 檔案新增到測試 target 中。否則你會得到預想不到的行為。

額外的思考

如果你使用一個持續整合的解決方案,讓你的測試啟動和執行是一個好主意。詳細的描述超過了本文的範圍。這些指令碼通過 RunUnitTests 指令碼觸發。還有個 TEST_AFTER_BUILD 環境變數。

一個有趣的選擇是建立單獨的測試 bundle 來自動化效能測試。你可以在測試方法裡做任何你想做的。定時呼叫一些方法並使用 STAssert 來檢查它們是否在特定閾值裡面是一種選擇。

擴充套件閱讀

Daniel Eggert, 2013 年 6 月


相關文章