如何進(jìn)行 UI 測試是 iOS 開發(fā)中很常見的問題 (我猜測 Mac 等其他 UI 驅(qū)動的平臺也是這樣)。很多人完全不做 UI 測試,問起來他們經(jīng)常這樣說:“你只應(yīng)該測試你的業(yè)務(wù)邏輯?!?也有一部分人想做 UI 測試,但是覺得它太復(fù)雜于是便放棄了。
每當(dāng)有人和我說 UI 測試很難的時(shí)候,我就會回想起在一次測試小組討論中,Landon Fuller 談到 Paper (by 53) 項(xiàng)目的 UI 測試時(shí)說的一段話:
你在屏幕上看到的是各種數(shù)據(jù)和變化綜合之后按照時(shí)間變化所得到的結(jié)果。如果你可以將這些東西分解成可供測試的單元的話,就意味著你可以將相對復(fù)雜的內(nèi)容拆解成更容易理解的元素。
Paper 的 UI 相對來說算是復(fù)雜的了,當(dāng)構(gòu)建這樣的 UI 的時(shí)候,可測試性一般不會被考慮在內(nèi)。但是,用戶的任何一個(gè)行為在代碼中都是被建模處理的,在測試中模仿用戶的行為是一件很容易的事情。而問題在于大多數(shù)框架,包括 UIKit,都沒有公開的暴露測試所需要的底層結(jié)構(gòu)。
知道 “測試什么” 和知道 “如何測試” 同等重要。我一直都在提及 “UI 測試”,因?yàn)檫@是一個(gè)被廣為接受的概念,我即將深入討論這類測試。實(shí)際上,我覺得你可以把 UI 測試分成兩類:1) 行為 和 2) 外觀.
我們無法確定地說某種 UI 的外觀是正確的,因?yàn)?UI 的外觀總是在頻繁的變化著。你肯定不想每次修改 UI 的時(shí)候都去修改 UI 的測試。但這并不意味著你無法測試外觀。我對于這個(gè)方面沒有任何經(jīng)驗(yàn),但是我們可以用截屏的方式檢驗(yàn)外觀。如果想進(jìn)一步的了解,可以閱讀 Orta 關(guān)于這方面的文章。
在開始之前友情提示各位,這篇文章將會探討用戶行為測試相關(guān)的內(nèi)容。我在Github上提供了一個(gè)項(xiàng)目,里面包含了一些實(shí)際的例子,雖然是使用Objective-C編寫的iOS項(xiàng)目,但是背后的原理是可以應(yīng)用于Mac和其他UI框架的。
在我測試用戶行為時(shí)的第一條原則是:使用代碼的形式來模擬事件觸發(fā),并讓它們就好像真的是由用戶的行所觸發(fā)的那樣。這可能會有點(diǎn)困難,因?yàn)檎缜懊嫠f,并不是所有的框架都會公開底層接口。
類似于 KIF、Frank 和 Calabash 的項(xiàng)目解決了這個(gè)問題,但是代價(jià)就是需要插入一個(gè)層額外的復(fù)雜度,而我們應(yīng)當(dāng)始終使用最簡單的可行方案。一般來說都測試的結(jié)果應(yīng)該是確定的,不修改的話要么就持續(xù)地失敗,要么就持續(xù)地成功。最糟糕的測試套件就是那些會隨機(jī)失敗的測試。我不會選擇去用那樣的方案,因?yàn)閺奈业慕?jīng)驗(yàn)來看,它們犧牲了可靠性和穩(wěn)定性而讓項(xiàng)目變得錯綜復(fù)雜。
注意到在示例項(xiàng)目中我使用了 Specta 和 Expecta,嚴(yán)格來講這并不是最簡單的解決方案,最簡單的解決方案是 XCText。但是又有很多原因讓我不得不提及它們。并且從我自己的開發(fā)經(jīng)驗(yàn)來看,它們并不會影響測試的可靠性和穩(wěn)定性。事實(shí)上,我敢打賭它們讓我的測試更好 (這是個(gè)安全的賭局,因?yàn)?strong>好是個(gè)模糊的概念的^_^)。
不管測試方法是什么,當(dāng)測試用戶行為的時(shí)候,我們總是想盡可能接近于用戶的真實(shí)操作。當(dāng)用戶與應(yīng)用交互的時(shí)候,我們往往希望能夠用代碼重現(xiàn)出來。想象一下,當(dāng)用戶看著一個(gè) ViewController,然后點(diǎn)擊了一個(gè)按鈕,彈出了一個(gè)新的 ViewController。你應(yīng)該是希望測試可以展示原始的 ViewConnector,并且實(shí)現(xiàn)點(diǎn)擊按鈕操作,然后確保呈現(xiàn)一個(gè)新的 ViewController。
專注于用代碼來模擬用戶交互,你可以一次驗(yàn)證多件事情。最重要的,你可以驗(yàn)證核實(shí)期望的行為。作為附贈,你也同時(shí)測試了控件正確被初始化以及它們的 action 是被正確設(shè)置的。
舉個(gè)例子,比如在某個(gè)測試中,我們直接調(diào)用了一個(gè)行為方法。這并不需要把你的測試和按鈕要做的事情連接起來,當(dāng)然實(shí)際上這樣的測試也不會去做這件事。但是如果按鈕的 target 或者 action 改變了,你的測試依舊可以通過。你希望證實(shí)的其實(shí)是按鈕在按照你的計(jì)劃行事。而至于按鈕調(diào)用什么方法,針對什么對象,這都不是在測試中該考慮的內(nèi)容。
UIKit 在 UIControl
里提供了非常有用的 sendActionsForControlEvents:
方法,我們可以用來模仿用戶操作。比如,用它來點(diǎn)擊按鈕:
[_button sendActionsForControlEvent: UIControlEventTouchUpInside];
類似地,調(diào)用這個(gè)函數(shù)來切換 UISegmentedControl
的選項(xiàng)卡:
segments.selectedSegmentIndex = 1;
[segments sendActionsForControlEvent: UIControlEventValueChanged];
注意在這里并不只是發(fā)送了 UIControlValueChanged
這個(gè)消息。當(dāng)一個(gè)用戶和控件交互的時(shí)候,它會先改變選中的 index 值,然后再發(fā)送 UIControlValueChanged
消息。這是一個(gè)非常好的例子,示范了如何通過代碼模擬用戶行為。
UIKit 中并不是所有的控件都有一個(gè)等價(jià)于 sendActionsForControlEvents:
的方法。但是只要有創(chuàng)造力的話,總是能找到變通的方法的。正如前面所說,最重要的是使用代碼去模擬用戶觸發(fā)了這個(gè)事件。
舉個(gè)例子,UITableView
并沒有函數(shù)用來選中單元格并且讓它去調(diào)用對應(yīng)的一系列委托方法。在示例項(xiàng)目中通過兩種方式實(shí)現(xiàn)了這個(gè)功能。
第一種方法是針對 storyboard 的:它通過手動觸發(fā)你希望的單元格來調(diào)用對應(yīng)的 segue。不幸的是,這并不能驗(yàn)證單元格都是和 segue 關(guān)聯(lián)的:
[_tableViewController performSegueWithIdentifier:@"TableViewPushSegue" sender:nil];
另一個(gè)選擇則不需要 storyboard 的參與,在測試代碼里手動調(diào)用 tableView:didSelectRowAtIndexPath:
這個(gè)委托方法。如果你使用 storyboard,你可以依舊使用segue,但是你需要從委托方法中手動調(diào)用:
[_viewController.tableView.delegate tableView:_viewController.tableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
expect(_viewController.navigationController.topViewController).to.beKindOf([PresentedViewController class]);
我更傾向于第二種選擇,它完全將測試從 ViewController 的呈現(xiàn)方式中解耦。它可以是一個(gè)自定義的 segue,或者 presentViewController:animated:completion
,或者是其他的甚至 Apple 還沒發(fā)明的方式。不過,所有測試所關(guān)心的是最后的 topViewController
屬性是不是像預(yù)期的一樣。最好的選擇是讓 TableView 自己去選中一行數(shù)據(jù)并且觸發(fā)對應(yīng)的響應(yīng) action,不過現(xiàn)在這個(gè)方法行不通。
作為測試控件的最后一個(gè)示例,我想展示一下 UIBarButtonItem
的特殊情況。它們沒有 sendActionsForControlEvent:
方法,因?yàn)樗鼈儧]有繼承自 UIControl
類。讓我們看看對于這樣的情況,如何發(fā)送按鈕事件,以及,對于我們的代碼而言,如何讓它看起來像是被用戶點(diǎn)擊了。
UIBarButtonItem
并不像 UIControl
,UIBarButtonItem
只擁有一個(gè) target 和一個(gè) action 與它關(guān)聯(lián)。調(diào)用這個(gè)事件很簡單:
[_viewController.barButton.target performSelector:_viewController.barButton.action
withObject:_viewController.barButton];
如果你在使用 ARC 那么編譯器會抱怨說無法從未知的 selector 中推斷出內(nèi)存管理的方式。這種狀況對我而言是不可接受的,因?yàn)樵谖已劾锞婢褪清e誤。
一個(gè)選擇是用 #pragma directive 來隱藏警告,另一個(gè)選擇就是使用直接使用runtime:
#import <objc/message.h>
objc_msgSend(_viewController.barButton.target, _viewController.barButton.action, _viewController.barButton);
我更喜歡 runtime 的方式,因?yàn)槲也幌矚g我的代碼被 pragma directives 搞得一團(tuán)糟。而且也因?yàn)樗o了我一個(gè)實(shí)際使用 runtime 的借口。
說句實(shí)話,我并不百分百的確定這些 "解決方案" 不會出問題,因?yàn)檫@并沒有解決根本的警告。測試的生命周期往往是短暫的,所以任何在測試操作中發(fā)生的內(nèi)存缺陷都不足以引起內(nèi)存問題。雖然在我使用的這段時(shí)間一直沒什么問題,但是其實(shí)我對這種情況也不是十分清楚,而且它有可能會隨機(jī)的在某個(gè)問題報(bào)出異常。如果有任何建議,歡迎在這里提出來。
在文章的最后,我想再說一說 ViewController。ViewController 可能是 iOS 應(yīng)用中最重要的部分,它被抽象出來調(diào)節(jié)視圖和業(yè)務(wù)邏輯的關(guān)系。為了能更好的測試用戶行為,我們不得不呈現(xiàn) ViewController。但是,在測試用例中呈現(xiàn) ViewController 讓我很快得出結(jié)論:在構(gòu)建它們的過程中,適合測試并不在考慮之內(nèi)。
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 是確保每個(gè)測試都有一個(gè)不變的初始狀態(tài)的最好方式。但是不幸的是,在連續(xù)快速的這樣做之后 -- 測試?yán)锟隙ǘ歼@么做 -- 很快就會導(dǎo)致下面的錯誤信息:
一套測試應(yīng)該盡可能的快,一直等到每一個(gè) ViewController 的展示結(jié)束是不可接受的。最終我們發(fā)現(xiàn),這些警告都是基于單窗口的。只要在獨(dú)立的窗口展示每一個(gè) ViewController,就可以給你的測試一個(gè)始終一致的開始狀態(tài),也保證它運(yùn)行起來足夠的快。通過在每個(gè)獨(dú)立的窗口展示分別,你就可以不需要等到展示或者消失過程結(jié)束了。
對于 ViewController 還有一些問題。比如,push 到導(dǎo)航控制器的操作發(fā)生在下一個(gè) run loop,而使用 modal 的方式彈出窗口卻不是這樣。如果你想嘗試一下這種測試方式,我建議你看一下我的 ViewController 測試助手,它會幫你解決這些問題。
當(dāng)測試行為的時(shí)候,你經(jīng)常需要證實(shí),在某個(gè)交互之后,一個(gè)新的 ViewController 可以正常的彈出來。換句話說,你需要證實(shí)當(dāng)前 ViewController 結(jié)構(gòu)的狀態(tài)。UIKit 在這個(gè)方面做的很好,它提供了一系列必要的方法,幫助你完成這個(gè)工作。比如下面這個(gè)例子,它可以讓你確定 ViewController 有沒有正確地以 modal 形式彈出:
expect(_viewController.presentedViewController).to.beKindOf([PresentedViewController class]);
或者以 push 進(jìn)導(dǎo)航控制器:
expect(_viewController.navigationController.topViewController).to.beKindOf([PresentedViewController class]);
UI測試其實(shí)并不難,只需要清楚你需要測試的內(nèi)容就行。你需要測試的是用戶交互,而不是應(yīng)用的外觀。通過創(chuàng)造力和不斷的堅(jiān)持,大多數(shù)框架的缺點(diǎn)都是可以通過變通的方法解決的,而不用犧牲測試套件的穩(wěn)定性和可維護(hù)性。時(shí)刻記著,讓你的測試盡可能接近用戶的真實(shí)操作。