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