1. 程式人生 > >Chaz.Zhao' s Blog

Chaz.Zhao' s Blog

如何進行 UI 測試是 iOS 開發中很常見的問題 (我猜測 Mac 等其他 UI 驅動的平臺也是這樣)。很多人完全不做 UI 測試,問起來他們經常這樣說:“你只應該測試你的業務邏輯。” 也有一部分人想做 UI 測試,但是覺得它太複雜於是便放棄了。

每當有人和我說 UI 測試很難的時候,我就會回想起在一次測試小組討論中,Landon Fuller 談到 Paper (by 53) 專案的 UI 測試時說的一段話:

你在螢幕上看到的是各種資料和變化綜合之後按照時間變化所得到的結果。如果你可以將這些東西分解成可供測試的單元的話,就意味著你可以將相對複雜的內容拆解成更容易理解的元素。

Paper 的 UI 相對來說算是複雜的了,當構建這樣的 UI 的時候,可測試性一般不會被考慮在內。但是,使用者的任何一個行為在程式碼中都是被建模處理的,在測試中模仿使用者的行為是一件很容易的事情。而問題在於大多數框架,包括 UIKit,都沒有公開的暴露測試所需要的底層結構。

知道 “測試什麼” 和知道 “如何測試” 同等重要。我一直都在提及 “UI 測試”,因為這是一個被廣為接受的概念,我即將深入討論這類測試。實際上,我覺得你可以把 UI 測試分成兩類:1) 行為 和 2) 外觀.

我們無法確定地說某種 UI 的外觀是正確的,因為 UI 的外觀總是在頻繁的變化著。你肯定不想每次修改 UI 的時候都去修改 UI 的測試。但這並不意味著你無法測試外觀。我對於這個方面沒有任何經驗,但是我們可以用截圖的方式檢驗外觀。如果想進一步的瞭解,可以閱讀 Orta 關於這方面的文章

在開始之前友情提示各位,這篇文章將會探討使用者行為測試相關的內容。我在Github上提供了一個

專案,裡面包含了一些實際的例子,雖然是使用Objective-C編寫的iOS專案,但是背後的原理是可以應用於Mac和其他UI框架的。

在我測試使用者行為時的第一條原則是:使用程式碼的形式來模擬事件觸發,並讓它們就好像真的是由使用者的行為所觸發的那樣。這可能會有點困難,因為正如前面所說,並不是所有的框架都會公開底層介面。

類似於 KIFFrank 和 Calabash 的專案解決了這個問題,但是代價就是需要插入一個層額外的複雜度,而我們應當始終使用最簡單的可行方案。一般來說都測試的結果應該是確定的,不修改的話要麼就持續地失敗,要麼就持續地成功。最糟糕的測試套件就是那些會隨機失敗的測試。我不會選擇去用那樣的方案,因為從我的經驗來看,它們犧牲了可靠性和穩定性而讓專案變得錯綜複雜。

注意到在示例專案中我使用了 Specta 和 Expecta,嚴格來講這並不是最簡單的解決方案,最簡單的解決方案是 XCTest。但是又有很多原因讓我不得不提及它們。並且從我自己的開發經驗來看,它們並不會影響測試的可靠性和穩定性。事實上,我敢打賭它們讓我的測試更好 (這是個安全的賭局,因為是個模糊的概念 ^_^)。

不管測試方法是什麼,當測試使用者行為的時候,我們總是想盡可能接近於使用者的真實操作。當用戶與應用互動的時候,我們往往希望能夠用程式碼重現出來。想象一下,當用戶看著一個 ViewController,然後點選了一個按鈕,彈出了一個新的 ViewController。你應該是希望測試可以展示原始的 ViewConnector,並且實現點選按鈕操作,然後確保呈現一個新的 ViewController。

專注於用程式碼來模擬使用者互動,你可以一次驗證多件事情。最重要的,你可以驗證核實期望的行為。作為附贈,你也同時測試了控制元件正確被初始化以及它們的 action 是被正確設定的。

舉個例子,比如在某個測試中,我們直接呼叫了一個行為方法。這並不需要把你的測試和按鈕要做的事情連線起來,當然實際上這樣的測試也不會去做這件事。但是如果按鈕的 target 或者 action 改變了,你的測試依舊可以通過。你希望證實的其實是按鈕在按照你的計劃行事。而至於按鈕呼叫什麼方法,針對什麼物件,這都不是在測試中該考慮的內容。

UIKit 在 UIControl 裡提供了非常有用的 sendActionsForControlEvents: 方法,我們可以用來模仿使用者操作。比如,用它來點選按鈕:

[_button sendActionsForControlEvent: UIControlEventTouchUpInside];

類似地,呼叫這個函式來切換 UISegmentedControl 的選項卡:

segments.selectedSegmentIndex = 1;
[segments sendActionsForControlEvent: UIControlEventValueChanged];

注意在這裡並不只是傳送了 UIControlValueChanged 這個訊息。當一個使用者和控制元件互動的時候,它會先改變選中的 index 值,然後再發送 UIControlValueChanged 訊息。這是一個非常好的例子,示範瞭如何通過程式碼模擬使用者行為。

UIKit 中並不是所有的控制元件都有一個等價於 sendActionsForControlEvents: 的方法。但是隻要有創造力的話,總是能找到變通的方法的。正如前面所說,最重要的是使用程式碼去模擬使用者觸發了這個事件。

舉個例子,UITableView 並沒有函式用來選中單元格並且讓它去呼叫對應的一系列委託方法。在示例專案中通過兩種方式實現了這個功能。

第一種方法是針對 storyboard 的:它通過手動觸發你希望的單元格來呼叫對應的 segue。不幸的是,這並不能驗證單元格都是和 segue 關聯的:

[_tableViewController performSegueWithIdentifier:@"TableViewPushSegue" sender:nil];

另一個選擇則不需要 storyboard 的參與,在測試程式碼裡手動呼叫 tableView:didSelectRowAtIndexPath: 這個委託方法。如果你使用 storyboard,你可以依舊使用segue,但是你需要從委託方法中手動呼叫:

[_viewController.tableView.delegate tableView:_viewController.tableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
expect(_viewController.navigationController.topViewController).to.beKindOf([PresentedViewController class]);

我更傾向於第二種選擇,它完全將測試從 ViewController 的呈現方式中解耦。它可以是一個自定義的 segue,或者presentViewController:animated:completion,或者是其他的甚至 Apple 還沒發明的方式。不過,所有測試所關心的是最後的topViewController 屬性是不是像預期的一樣。最好的選擇是讓 TableView 自己去選中一行資料並且觸發對應的響應 action,不過現在這個方法行不通。

作為測試控制元件的最後一個示例,我想展示一下 UIBarButtonItem 的特殊情況。它們沒有 sendActionsForControlEvent: 方法,因為它們沒有繼承自 UIControl 類。讓我們看看對於這樣的情況,如何傳送按鈕事件,以及,對於我們的程式碼而言,如何讓它看起來像是被使用者點選了。

UIBarButtonItem 並不像 UIControlUIBarButtonItem 只擁有一個 target 和一個 action 與它關聯。呼叫這個事件很簡單:

[_viewController.barButton.target  performSelector:_viewController.barButton.action
                                         withObject:_viewController.barButton];

如果你在使用 ARC 那麼編譯器會抱怨說無法從未知的 selector 中推斷出記憶體管理的方式。這種狀況對我而言是不可接受的,因為在我眼裡警告就是錯誤。

一個選擇是用 #pragma directive 來隱藏警告,另一個選擇就是使用直接使用runtime:

#import <objc/message.h>

objc_msgSend(_viewController.barButton.target, _viewController.barButton.action, _viewController.barButton);

我更喜歡 runtime 的方式,因為我不喜歡我的程式碼被 pragma directives 搞得一團糟。而且也因為它給了我一個實際使用 runtime 的藉口。

說句實話,我並不百分百的確定這些 "解決方案" 不會出問題,因為這並沒有解決根本的警告。測試的生命週期往往是短暫的,所以任何在測試操作中發生的記憶體缺陷都不足以引起記憶體問題。雖然在我使用的這段時間一直沒什麼問題,但是其實我對這種情況也不是十分清楚,而且它有可能會隨機的在某個問題報出異常。如果有任何建議,歡迎在這裡提出來

在文章的最後,我想再說一說 ViewController。ViewController 可能是 iOS 應用中最重要的部分,它被抽象出來調節檢視和業務邏輯的關係。為了能更好的測試使用者行為,我們不得不呈現 ViewController。但是,在測試用例中呈現 ViewController 讓我很快得出結論:在構建它們的過程中,適合測試並不在考慮之內。

Presenting and dismissing view controllers is the best way to make sure every test has a consistent start state. Unfortunately, doing so in rapid succession—like a test runner does—will quickly result in error messages like:

顯示和隱藏 ViewController 是確保每個測試都有一個不變的初始狀態的最好方式。但是不幸的是,在連續快速的這樣做之後 -- 測試裡肯定都這麼做 -- 很快就會導致下面的錯誤資訊:

  • Warning: Attempt to dismiss from view controller <UINavigationController: 0x109518bd0> while a presentation or dismiss is in progress!
  • Warning: Attempt to present <PresentedViewController: 0x10940ba30> on <UINavigationController: 0x109518bd0> while a presentation is in progress!
  • Unbalanced calls to begin/end appearance transitions for <UINavigationController: 0x109518bd0>
  • nested push animation can result in corrupted navigation bar

一套測試應該儘可能的快,一直等到每一個 ViewController 的展示結束是不可接受的。最終我們發現,這些警告都是基於單視窗的。只要在獨立的視窗展示每一個 ViewController,就可以給你的測試一個始終一致的開始狀態,也保證它執行起來足夠的快。通過在每個獨立的視窗展示分別,你就可以不需要等到展示或者消失過程結束了。

對於 ViewController 還有一些問題。比如,push 到導航控制器的操作發生在下一個 run loop,而使用 modal 的方式彈出視窗卻不是這樣。如果你想嘗試一下這種測試方式,我建議你看一下我的 ViewController 測試助手,它會幫你解決這些問題。

當測試行為的時候,你經常需要證實,在某個互動之後,一個新的 ViewController 可以正常的彈出來。換句話說,你需要證實當前 ViewController 結構的狀態。UIKit 在這個方面做的很好,它提供了一系列必要的方法,幫助你完成這個工作。比如下面這個例子,它可以讓你確定 ViewController 有沒有正確地以 modal 形式彈出:

expect(_viewController.presentedViewController).to.beKindOf([PresentedViewController class]);

或者以 push 進導航控制器:

expect(_viewController.navigationController.topViewController).to.beKindOf([PresentedViewController class]);

UI測試其實並不難,只需要清楚你需要測試的內容就行。你需要測試的是使用者互動,而不是應用的外觀。通過創造力和不斷的堅持,大多數框架的缺點都是可以通過變通的方法解決的,而不用犧牲測試套件的穩定性和可維護性。時刻記著,讓你的測試儘可能接近使用者的真實操作。