你可能先前已經聽過自動化測試,尤其是在討論軟體品質的相關議題時,我們往往都會談論到自動化測試這個名詞。如果你不幫自己的專案寫任何的測試,可能會讓你遇上大麻煩,就算當下你感覺不到,但是長期來看,它將會累積成為很龐大的技術債務。
確實如此。
專案如果沒有寫測試,當越來越多開發者參與其中,並且隨著這個專案變得更大更複雜以後,要維護它幾乎是不可能的任務,當你未來更動到code,將會發現運作時出現問題,而且甚至是當老闆站在你桌子前面開始為了這個bug大聲斥責時,才會發現這個問題,我相信你對這個情境很熟悉,對吧。
所以,開發者最好要去了解如何使用測試,這樣一來,將得以改善你專案的品質,並且讓自己成為更優質的軟體工程師。
在iOS當中,有兩種類型的測試:
- Unit test(單元測試):
- 在class裡面測試某一個特定的動作。
- 請確保這個動作在該類別運行時是獨立作業的。
- UI test(介面測試):
- 它也被稱為整合測試。
- 用來測試在app運行時,使用者的每個動作是否與預期相同。
- 確保全部的類別在作業時都妥善配合在一起。
上述兩種測試都一樣重要。
如果你只有寫unit tests,但完全忽略了UI tests,你將會陷入下列情境中:

如你所見,就像上圖的兩扇窗戶將無法順利配對運作,當把它們放在一起,糗事發生了,你可以看到畫面中的男子面對這個情況如此無奈。😉
UI tests是相當簡單的,甚至比unit tests更簡單。
今天我們將要學習一些非常基本的UI tests,讀者將可以從頭到尾看到一個功能齊全的app實作過程。
我們將實作什麼類型的應用程式呢?
它會是一個簡單的做筆記app,並擁有下列這些功能:
- 透過使用者名稱與密碼進行登入。
- 檢視已紀錄的一系列筆記。
- 添加新的筆記。
- 更新既有的筆記。
- 刪除一個筆記。
下列為一個GIF圖檔,它將會顯示這個app實作出的全部功能:

看起來非常簡單,對吧?
儘管如此,今天我們的工作,並不是如何建立這個類型的app,而是去學習如何幫這個應用程式寫出UI tests,必須涵蓋每一個螢幕頁面以及功能,這將是最精彩的部分。
自動化介面測試運行時是長什麼樣子呢?
讀者可以進入下列網址看一下這個video:
當測試代碼開始運行時,將它模擬使用者的每種操作行為:
- 將資料填入text field。
- 點擊按鈕。
- 滑動頁面。
它將跑遍全部的螢幕頁面,並且依序測試每一個使用情境:
- 在登入頁面中,將測試模擬下列情境:
- 填入正確的使用者名稱與密碼。
- 密碼欄位空白。
- 使用者名稱欄位空白。
- 輸入錯誤的使用者名稱與密碼。
- 在首頁中,將測試模擬下列情境:
- 添加新的筆記。
- 刪除筆記。
- 編輯已存在的筆記。
我不知道你是否認同,但我必須說,它看起來相當有趣。
啟動UI tests去看它自動跑遍整個操作流程是相當有趣的經驗,下次如果有人請你展示你的app,就將你寫的UI tests運行起來吧,它將會帶來讓人驚奇且印象深刻的效果。😜
為什麼我們需要寫自動化UI tests呢?
這邊列出幾個比較重要的好處:
- 避免regression bugs,並且將需要手動測試的時間縮到最小::
事實上,一般來說這就是自動化測試帶來的好處。
當你對程式碼進行了一些修改,這些測試可以確保你不會影響任何功能的運作,讓你的app仍然可以如預期般順利執行。
如果沒有寫任何測試,未來就算是對程式只做了微幅調整,都是非常危險的,必須要很小心的去做大量手動測試,才能對更改後的程式碼重拾信心。
如果沒有任何的測試,我們將需要一整個QC(Quality Control)團隊去測試這份程式碼的bug,現在不需要這麼麻煩了,只要透過UI tests即可完善地達成上述工作。
我沒有說過要全部依賴自動化測試,程式中仍有一部份是測試無法完全覆蓋到的,所以我們仍是需要針對這部份進行手動測試,儘管如此,需要手動測試的部分仍要力求最小化。
- 協助測試view controllers:
UIKit的框架將所有元件緊密配合在一起(the window, the view, the controller, the app lifecycle),這使得它很難去針對view controllers寫單元測試,儘管是最簡單的測試情境需求。
就算它仍然是可以實作的,但我們必須透過mock以及stub進行,藉由它們去模擬物件以及觸發事件,而且結果可能是花費大量的工作卻一點也不管用。
當我們有其他方式可以駕馭它時,為什麼要逆勢而行?
UI test將我們放在使用者的角度進行測試,使用者並不會一定按照開發者的設計邏輯進行操作,他們想要做的可能就只是點擊按鈕,預期有東西出現在畫面螢幕中,就這樣而已。
-
幫忙註記你的程式碼:
請看這裡:
func testWrongUsernameOrPassword() {
fillInUsername()
fillInWrongPassword()
tapButton("Login")
expectToSeeAlert("Username or password is incorrect")
tapButton("OK")
}
這是UI test的其中一個測試情境,當使用者嘗試用錯誤的資訊進行登入時(使用者名稱或是密碼)。
它相當簡單明瞭,所以其實不需要我多解釋上述的測試情境為何。
只需要閱讀這些測試方法,就可以快速瞭解這個應用程式可以做哪些事情,換言之,它就是最好的敘述文件。
- 提供一個視覺化操作,完整運行你的應用程式:
就如我先前提到的,整個操作過程相當有趣,而且它是值得你去使用的,將會讓你一用就愛上。
說真的,我確定你將會愛上它。
好了!我希望你已經對學習UI testings感到興奮,讓我們繼續往下延伸吧。
了解UI testing
一個典型的UI test可能看起來像這樣(在虛擬程式碼中):
將資料填入text field 點擊按鈕 預期將前往下一個頁面
就像你看到的,UI test是透過一連串使用者的操作動作建構而成,它就是按部就班遵循指令按照預期反應去執行,一般的形式如下:
執行一個動作 預期某個事件發生
舉例來說:
點擊一個table cell 預期它會展開 再次點擊 預期它將閃退
這邊再舉一個例子:
下拉進行畫面重整 預期將在畫面頂端看到新的內容 滑動刪除一個row 預期這個row從table view中被刪除
在UI tests當中,相較於底層程式碼的架構,我們更關心的是使用者與應用程式的互動情況,專注在使用者做了某個動作以後,他將會從螢幕畫面中得到什麼回應,其他所有的背景物件在這裡不是我們要關心的。
如何在Swift中使用UI test?
我們會需要一個叫做KIF的framework,它將提供一整套的APIs去處理介面操作的問題,舉例來說:
”隨意將一些文字”填寫進text field當中。
tester().enterText("some random text", intoViewWithAccessibilityLabel: "my text field")
點擊一個按鈕。
tester().tapViewWithAccessibilityLabel("my button")
你可能會想:上面出現的accessibility label是什麼東西?
事實上,它提供一個方法,讓我們可以從螢幕中尋找到某一個UI元件。
換句話說,accessibility label透過你指定的名稱,可以從每一個UIView中將這個名稱所屬的元件挑出來。
當你想要點擊某一個按鈕,必須將你指定的按鈕告知這個framework,因此,你必須將名稱賦值給accessibility label(這邊我們將其命名為“my button”),然後對他進行點擊的動作:
tester().tapViewWithAccessibilityLabel("my button")
這裡我們在列出更多的例子來說明:
預期某一個view(將“my view”這個字串賦值給accessibility label)顯示在螢幕上:
tester().waitForViewWithAccessibilityLabel("my view")
預期這個view從螢幕上消失:
tester().waitForAbsenceOfViewWithAccessibilityLabel("my view")
透過accessibility label將名為“my view”的元件存入另一個view當中:
let myView = tester().waitForViewWithAccessibilityLabel("my view") as! UIView
在“my view”這個頁面添加向左滑動的操作指令:
tester().swipeViewWithAccessibilityLabel("my view", inDirection: .Left)
如果讀者想瞭解KIF框架提供的全部UI操作指令,可以點擊這裡閱覽相關介紹。
我們該如何將accessibility label放置到UIView裡面?
這邊有兩個方式提供我們使用:
- 使用Storyboard:
- 打開Storyboard.
- 點擊一個你置放在view裡面的accessbility label
- 選點Identity Inspector這個tab。

- 向下滑動至Accessibility的部分。
- 將你指定的accessbility label名稱填入Label區塊中. (在這裡我們添入“Login – Username”)

對於UITableView以及UICollectionView,將不會有Accessibility可以使用,我不知道為何蘋果這樣設計,儘管如此,我們仍是有方法可以解決的:

基本上,我們必須去設定key path,讓它能與UITableView的屬性配對,當程式運行時,它將讀取key path裡面的值,並設定相對應的屬性。
另外一個值得注意的是:UIButton或者UILabel,它將會有預設的accessibility label,且與text屬性相同,假設你有一個按鈕,其中的text為“click me”,那它的accessibility label同樣也是“click me”,你不需要重新去設定一次。
- 使用程式碼:
如果你在Storyboard中使用到text field,請幫這個元件建立@IBOutlet:
@IBOutlet weak var usernameTextField: UITextField!
接者你可以:
usernameTextField.accessibilityLabel = "my username textfield"
雖然使用程式碼看起來比較簡單,但還是建議盡可能透過Storyboard,直接對元件設定accessibility label,因為,The best code is no code。
準備開始這個專案
首先,在這裡下載專案,接者請把專案run起來,確保它可以順利運行。
在我們進入下一個步驟前,可以簡單地看一下這個app,增加對它的了解。
進行UI testing之前,如何設定KIF?
Step 1: 使用cocoapods,安裝KIF和Nimble。
添加pod 'KIF'和pod 'Nimble'到你的Podfile,記得將它們放置在test target。
Nimble這個framework提供更好的測試方法,以符合開發者的預期,我曾經寫過一個文章談論過這件事,請點擊這裡前往。
platform :ios, '9.0'
target 'SimpleNoteTakingApp' do
use_frameworks!
#...
target 'SimpleNoteTakingAppTests' do
inherit! :search_paths
pod 'KIF'
pod 'Nimble'
end
end
打開終端機並且運行下面這行代碼:
pod install
Step 2: 創建KIF helper
在你的test target裡面建立一個KIF+Extensions.swift檔案。
import KIF
extension XCTestCase {
func tester(file : String = #file, _ line : Int = #line) -> KIFUITestActor {
return KIFUITestActor(inFile: file, atLine: line, delegate: self)
}
func system(file : String = #file, _ line : Int = #line) -> KIFSystemTestActor {
return KIFSystemTestActor(inFile: file, atLine: line, delegate: self)
}
}
extension KIFTestActor {
func tester(file : String = #file, _ line : Int = #line) -> KIFUITestActor {
return KIFUITestActor(inFile: file, atLine: line, delegate: self)
}
func system(file : String = #file, _ line : Int = #line) -> KIFSystemTestActor {
return KIFSystemTestActor(inFile: file, atLine: line, delegate: self)
}
}
Step 3: 生成briding header
在你的test target創建一個Objective-C檔案,名稱可以隨便取。

Xcode將會詢問你是否要增加一個briding header檔案,點擊 Create Bridging Header。

刪除剛剛臨時創建的ObjC檔案,我們已經不需要他了。
在這個bridging header(SimpleNoteTakingApp-Bridging-Header.h)檔案中Import KIF。
#import
Step 4: 建立我們的第一個UI test
在test target建立一個新的檔案,並將它命名為LoginTests.swift。
import KIF
class LoginTests : KIFTestCase {
func testSomethingHere() {
tester().tapViewWithAccessibilityLabel("hello")
}
}
請注意:
- 你的UI test必須為KIFTestCase的子類別。
- 每一個測試的方法,命名時都要以test開頭,例如:testA, testB, testLogin, testSomethingElse…等。
現在請將這個測試運行起來,並觀察它的作業情況(快捷鍵:Cmd + U)。
它將會打開iOS模擬器,並且將畫面停在登入頁面,在等待幾秒之後就閃退。
這是因為我們還沒有在任何view上放置hello的accessibility label,稍後來修復這個問題,但是現在,我們的第一個UI test已經順利跑起來了,它看起來很酷吧。
讓我們開始替這個筆記app寫UI tests吧
測試登入頁面:
登入頁面有四個情境:
情境一: 使用者名稱與密碼空白
在這個情境中,使用者會看到一個提示訊息,內容為「使用者欄位不能空白」。
在我們進入下一步之前,希望讀者可以花一點時間去思考如何設計這個情境:哪些步驟需要實作,預期的結果為何?
- 首先,我們必須先將使用者名稱及密碼欄位清空。
- 隨後點擊登入按鈕。
- 我們預期將出現提示訊息 “使用者欄位為空白”。
事實上,這邊有更好的方式來展現這些步驟,就是使用Gherkin format,如何使用它來建立基礎情境架構,請參考下列敘述:
情境: 該情境名稱 設定一些先決條件 當我做了某些動作 隨後預期某些事件被觸發 ...
在我們的案例中,這個情境敘述將變成:
情境: 使用者名稱以及密碼為空白 給定已經清空的使用者名稱以及密碼欄位 當我點擊“登入”按鈕 預期會看到提示訊息 “使用者欄位不得為空白”
它可以只是在紙上做一些簡單紀錄,或是直接在腦中設想,但它非常貼近人的語言,所以任何人都可以閱讀並且理解。
接者,請把它轉換為Swift。
打開LoginTests.swift,開始寫我們第一個測試,再次提醒,測試名稱一定要以test做開頭。
func testEmptyUsernameAndPassword() {
}
開始執行我們的第一步:將這些欄位清空。
func testEmptyUsernameAndPassword() {
clearOutUsernameAndPasswordFields()
}
雖然clearOutUsernameAndPasswordFields尚未被定義,但是不用擔心,我們就先將它寫入,之後在去修正出現的錯誤。
下一步就是點擊”登入”按鈕:
func testEmptyUsernameAndPassword() {
clearOutUsernameAndPasswordFields()
tapButton("Login")
}
同樣的,這個tapButton方法還沒被定義,但我們先將它放置進來,用來建構這個測試。
接著,我們剩下的步驟就是重複剛剛的動作:
func testEmptyUsernameAndPassword() {
clearOutUsernameAndPasswordFields()
tapButton("Login")
expectToSeeAlert("Username cannot be empty")
tapButton("OK")
}
現在我們已經使用Swift將整個情境寫入,接下來,要開始將剛剛尚未被定義的方法逐一完成。
先將這個text field清空,我們使用KIF的方法clearTextFromViewWithAccessibilityLabel,看名稱就大致能理解它的用途,而clearOutUsernameAndPasswordFields函式如下:
func clearOutUsernameAndPasswordFields() {
tester().clearTextFromViewWithAccessibilityLabel("Login - Username")
tester().clearTextFromViewWithAccessibilityLabel("Login - Password")
}
關於tapButton函式:
func tapButton(buttonName: String) {
tester().tapViewWithAccessibilityLabel(buttonName)
}
另外,expectToSeeAlert函式請參考下列程式碼:
func expectToSeeAlert(text: String) {
tester().waitForViewWithAccessibilityLabel(text)
}
此刻LoginTests.swift內容如下:
import KIF
class LoginTests : KIFTestCase {
func testEmptyUsernameAndPassword() {
clearOutUsernameAndPasswordFields()
tapButton("Login")
expectToSeeAlert("Username cannot be empty")
tapButton("OK")
}
func clearOutUsernameAndPasswordFields() {
tester().clearTextFromViewWithAccessibilityLabel("Login - Username")
tester().clearTextFromViewWithAccessibilityLabel("Login - Password")
}
func tapButton(buttonName: String) {
tester().tapViewWithAccessibilityLabel(buttonName)
}
func expectToSeeAlert(text: String) {
tester().waitForViewWithAccessibilityLabel(text)
}
}
現在將你的測試跑起來吧,請按下Cmd + U。
模擬器將會彈出並且很神奇的自動按照你指定步驟運行。

測試應該會順利通過。(因為我們已經將有所有功能實作出來)
這邊我們進行一些程式碼重構(refactor)的作業。建立一個新檔案LoginSteps.swift,並且將所有的方式移至其中。
extension LoginTests {
func clearOutUsernameAndPasswordFields() {
tester().clearTextFromViewWithAccessibilityLabel("Login - Username")
tester().clearTextFromViewWithAccessibilityLabel("Login - Password")
}
func tapButton(buttonName: String) {
tester().tapViewWithAccessibilityLabel(buttonName)
}
func expectToSeeAlert(text: String) {
tester().waitForViewWithAccessibilityLabel(text)
}
}
這樣一來,LoginTests.swift將變得更簡潔易讀。
import KIF
class LoginTests : KIFTestCase {
func testEmptyUsernameAndPassword() {
clearOutUsernameAndPasswordFields()
tapButton("Login")
expectToSeeAlert("Username cannot be empty")
tapButton("OK")
}
}
情境二: 密碼欄位為空白。
同樣的,一開始我們先設想這個情境該如何運作:
情境: 密碼欄位空白 先將使用者名稱與密碼欄位清空 當我填入使用者名稱後 點擊"登入"按鈕 然後,預期將出現提示訊息”密碼欄位不得空白”
將上述步驟改用程式碼表達:
func testEmptyPassword() {
clearOutUsernameAndPasswordFields()
fillInUsername()
tapButton("Login")
expectToSeeAlert("Password cannot be empty")
tapButton("OK")
}
fillInUsername這個方法名稱也是讓人一目瞭然。
func fillInUsername() {
tester().enterText("appcoda", intoViewWithAccessibilityLabel: "Login - Username")
}
請記得,為了讓測試的程式碼看起來比較乾淨,這個函式請放在LoginSteps.swift,而非LoginTests.swift。
運行這個測試,確保它可以通過。
注意,這兩個測試目前都要會使用(clearOutUsernameAndPasswordFields),我們將它移動beforeEach函式中,在這裡,將用來放置測試前想要執行的動作。
class LoginTests : KIFTestCase {
override func beforeEach() {
clearOutUsernameAndPasswordFields()
}
func testEmptyUsernameAndPassword() {
tapButton("Login")
expectToSeeAlert("Username cannot be empty")
tapButton("OK")
}
func testEmptyPassword() {
fillInUsername()
tapButton("Login")
expectToSeeAlert("Password cannot be empty")
tapButton("OK")
}
}
現在我們對寫UI tests比較熟悉了,接下來篇幅將會用比較快的節奏進行。
情境三: 錯誤的使用者名稱或是密碼
情境設計:
情境: 錯誤的使用者名稱或是密碼 先將使用者名稱與密碼欄位清空 填入使用者名稱 並且填入一組錯誤的密碼 點擊”登入” 按鈕 然後,預期將出現提示訊息”使用者名稱或密碼欄位輸入錯誤”
程式碼實作:
func testWrongUsernameOrPassword() {
fillInUsername()
fillInWrongPassword()
tapButton("Login")
expectToSeeAlert("Username or password is incorrect")
tapButton("OK")
}
請注意,該情境的第一個步驟已經被寫入beforeEach,所以我們在這邊不需要再去呼叫它。
關於fillInWrongPassword函式:
func fillInWrongPassword() {
tester().enterText("wrongPassword", intoViewWithAccessibilityLabel: "Login - Password")
}
情境四: 輸入正確的使用者名稱與密碼
情境設計:
情境: 輸入正確的使用者名稱與密碼 先將使用者名稱與密碼欄位清空 當我填入一組使用者名稱 並且填入正確的密碼 接者點擊”登入”按鈕 預期將進入到首頁
程式碼實作:
func testCorrectUsernameAndPassword() {
fillInUsername()
fillInCorrectPassword()
tapButton("Login")
expectToGoToHomeScreen()
}
關於fillInCorrectPassword函式:
func fillInCorrectPassword() {
tester().enterText("correctPassword", intoViewWithAccessibilityLabel: "Login - Password")
}
上述這組密碼是正確的,因為我們已經先將這組帳密設定好了。(帳號”appcoda”搭配密碼”correctPassword”) 😜
針對expectToGoToHomeScreen這個函式,我們該如何得知我們已經移動到另一個螢幕頁面呢?
我們透過下列情況判斷:
- 預期登入畫面中的UI元件消失。
- 並預期看到首頁上的相關UI元件。
func expectToGoToHomeScreen() {
// expect login screen to disappear
tester().waitForAbsenceOfViewWithAccessibilityLabel("Login - Username")
tester().waitForAbsenceOfViewWithAccessibilityLabel("Login - Password")
tester().waitForAbsenceOfViewWithAccessibilityLabel("Login")
// expect to see Home screen
tester().waitForViewWithAccessibilityLabel("No notes")
tester().waitForViewWithAccessibilityLabel("Add note")
}
針對首頁進行測試:
新增一個檔案HomeTests.swift。
import KIF
class HomeTests: KIFTestCase {
}
與它相對應的檔案為HomeSteps.swift。
extension HomeTests {
}
現在我們有一個以上的測試class(LoginTests以及HomeTests),我們這邊需要一個共用的方法,讓我們針對相同的步驟得以重複使用,因此,這邊請建立一個base class,命名為BaseUITests.swift。
class BaseUITests: KIFTestCase {
}
請讓LoginTests和HomeTest都繼承這個類別。
// in LoginTests.swift
class LoginTests: BaseUITests { ... }
// in HomeTests.swift
class HomeTests: BaseUITests { ... }
建立另外一個名為CommonSteps.swift的檔案,將共用的函式全部移動到這裡面。
extension BaseUITests {
// move common steps here
}
當你針對某個步驟寫對應函式時,請先確認將它放置在正確的位置:
- 放置在
CommonSteps.swift裡面的程式碼,應該是要在各個測試類別可以共用的步驟。 - 如果某個步驟只在特定頁面才會使用到,那就將這些函式放置在該頁面對應的檔案中。(例如:
LoginSteps.swift或是HomeSteps.swift)
// in CommonSteps.swift
extension BaseUITests {
// common steps
}
// in LoginSteps.swift
extension LoginTests {
// step specific for Login screen
}
// in HomeSteps.swift
extension HomeTests {
// step specific for Home screen
}
在首頁中,我們需要實作出筆記應用程式的新增/編輯/刪除的功能,所以這個頁面將是負責處理很多資料操作的地方。
我們不想要在每次運行UI測試時,將大量的測試紀錄存進產品的資料庫中,因此,這裡將建立一個測試資料庫,供我們的測試環境使用。
自從我使用Realm當作數據庫層,只要寫一行程式碼就可以將測試資料庫設置完成:
func useTestDatabase() {
Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "put any name here"
}
這邊將在記憶體中建立一個Realm資料庫,它只有在測試運行時才會存在。
你的專案可能使用的是不同的資料庫存取技術(CoreData, FMDB, SQLite),但是它們的觀念都是相同的,你創建一個測試資料庫檔案,並將全部測試紀錄都塞到裡面,不會對專案主資料庫的檔案造成影響。
我們會把這個資料庫設定放在beforeAll函式中,它只會被執行一次。
class HomeTests: KIFTestCase {
override func beforeAll() {
useTestDatabase()
}
}
現在,我們在首頁中也會面臨四種情境。
情境一:當這裡沒有任何筆記(notes),label需要顯示”No notes”字串
首頁並不是初始頁面,我們必須在進行其他操作前,先執行一個前往首頁的動作。
情境: 如果沒有筆記, label需要顯示"No notes”字串 設定這裡沒有任何筆記 當我前往首頁時 將會看到一個label顯示"No notes"字串 因此,預期不會看到note列表
程式碼實作:
func testNoNotes() {
haveNoNotes()
visitHomeScreen()
expectToSeeLabel("No notes")
expectNotToSeeNoteList()
}
在haveNoNotes函式中,我們將會從資料庫把全部紀錄刪除:
func haveNoNotes() {
let realm = try! Realm()
try! realm.write {
realm.deleteAll()
}
}
針對visitHomeScreen函式,它有一點麻煩,因為在前一個測試完成後,你可能會不知道目前在哪個畫面中,有可能是在登入頁面、首頁或是其他頁面。
就像當你不知道此刻位在什麼地方時,很難朝另外一個地方前進,對吧?
儘管如此,如果我們移動到初始頁面(登入頁),這邊通常會有至少一種方式可以前往app當中的任一頁面。
因此,我們的解決方案為,不管你目前所在的頁面,請先回到初始頁面當中,這樣一來,你可以很輕易的前進到其他頁面中。
但是我們要怎麼做呢?
首先,要去抓取一個這個應用程式rootViewController的reference:
let rootViewController = UIApplication.sharedApplication().keyWindow?.rootViewController
接著要依據你的app架構,將會透過不同的方式進行,在我的案例中,它位於navigation controller的最頂層,所以我需要做的事情就是跳回navigation層裡面的第一個controller。
func backToRoot() {
if let rootViewController = UIApplication.sharedApplication().keyWindow?.rootViewController as? UINavigationController {
rootViewController.popToRootViewControllerAnimated(false)
}
}
在這裡,我們會將它放在beforeEach方法中,每回當一個測試被執行時,它必須先回到初始頁面中。
接者,建議清空資料庫以確保後續測試都會重新啟動,我們也將haveNoNotes這個步驟移動至 beforeEach函式裡面。
class HomeTests: KIFTestCase {
override func beforeAll() {
useTestDatabase()
}
override func beforeEach() {
backToRoot()
haveNoNotes()
}
func testNoNotes() {
visitHomeScreen()
expectToSeeLabel("No notes")
expectNotToSeeNoteList()
}
}
關於visitHomeScreen函式如下:
func visitHomeScreen() {
fillInUsername()
fillInCorrectPassword()
tapButton("Login")
}
expectToSeeLabel函式如下:
func expectToSeeLabel(label: String) {
tester().waitForViewWithAccessibilityLabel(text)
}
expectNotToSeeNoteList函式如下:
func expectNotToSeeNoteList() {
tester().waitForAbsenceOfViewWithAccessibilityLabel("Note - Table View")
}
情境二:建立新的筆記。
情境設計:
情境: 創建新筆記 設定這裡沒有任何筆記 當我前往首頁 並且點擊“增加筆記”按鈕 預期這個新增按鈕被設置為不可點擊 把筆記的title填入”new note” 之後預期新增按鈕會變為可以點擊狀態 將筆記的body填入"new body" 點擊“建立”按鈕 預期會看到一個筆記位於第0列,title為”new note”且body為”new body" 我預期在列表中,筆記的數量為1
這邊說明一個運作邏輯,只有在title欄位有文字資料時,建立按鈕才能夠點擊,否則它將處於disabled的狀態。
程式碼實作:
func testCreateNewNote() {
haveNoNotes()
visitHomeScreen()
tapButton("Add note")
expectTheCreateButtonToBeDisabled()
fillInNoteTitle("new note")
expectTheCreateButtonToBeEnabled()
fillInNoteBody("new body")
tapButton("Create")
expectToSeeNoteWithTitle("new note", body: "new body", atRow: 0)
expectNumberOfNotesInListToEqual(1)
}
針對expectTheCreateButtonToBeDisabled函式,我們必須:
- 首先,取得Create按鈕的reference。
- 透過Nimber宣告它的屬性. (如果你不知Nimble是什麼,請參考這裡)
func expectTheCreateButtonToBeDisabled() {
let createButton = tester().waitForViewWithAccessibilityLabel("Create") as! UIButton
expect(createButton.enabled) == false
}
expectTheCreateButtonToBeEnabled函式也是相同:
func expectTheCreateButtonToBeEnabled() {
let createButton = tester().waitForViewWithAccessibilityLabel("Create") as! UIButton
expect(createButton.enabled) == true
}
fillInNoteTitle與 fillInNoteBody相當簡單,我們只需要在欄位中填入一些文字。
expectToSeeNoteWithTitle這個函式也可以使用相同邏輯去實作。
- 獲得這個cell的reference。
- 宣告它的屬性。
func expectToSeeNoteWithTitle(title: String, body: String, atRow row: NSInteger) {
let indexPath = NSIndexPath(forRow: row, inSection: 0)
let noteCell = tester().waitForCellAtIndexPath(indexPath, inTableViewWithAccessibilityIdentifier: "Note - TableView")
expect(noteCell.textLabel?.text) == title
expect(noteCell.detailTextLabel?.text) == body
}
expectNumberOfNotesInListToEqual函式如下:
func expectNumberOfNotesInListToEqual(count: Int) {
let noteTableView = tester().waitForViewWithAccessibilityLabel("Note - TableView") as! UITableView
expect(noteTableView.numberOfRowsInSection(0)) == count
}
情境三: 編輯一個筆記
情境設計:
情境: 編輯一個筆記 給定三個筆記 當我前往首頁時 我在點擊第一個列上的筆記 我更新筆記的title為"updated note" 我更新筆記的body為"updated body" 接者點擊”更新”按鈕 然後我預期會在第一個列上看到title為”updated note”且body為”updated body”的筆記
程式碼實作:
func editANote() {
have3Notes()
visitHomeScreen()
tapOnNoteAtRow(1)
updateNoteTitleTo("updated note")
updateNoteBodyTo("updated body")
tapButton("Update")
expectToSeeNoteWithTitle("updated note", body: "updated body", atRow: 1)
}
have3Notes函式:我們將新增三筆紀錄到Realm資料庫。
func have3Notes() {
let realm = try! Realm()
try! realm.write {
for i in 0...2 {
let note = Note()
note.title = "title \(i)"
note.body = "body \(i)"
realm.add(note)
}
}
}
tapOnNoteAtRow函式如下:
func tapOnNoteAtRow(row: Int) {
let indexPath = NSIndexPath(forRow: row, inSection: 0)
tester().tapRowAtIndexPath(indexPath, inTableViewWithAccessibilityIdentifier: "Note - TableView")
}
情境四: 刪除筆記
情境設計:
情境:刪除筆記 給定三個筆記資料 當前進首頁時 刪除一個筆記資料 預期資料列表數為變為兩筆 刪除一個筆記資料 預期資料列表數為變為一筆 刪除一個筆記資料 將會看到一個label顯示"No notes”的訊息
程式碼實作:
func deleteNotes() {
have3Notes()
visitHomeScreen()
deleteANote()
expectNumberOfNotesInListToEqual(2)
deleteANote()
expectNumberOfNotesInListToEqual(1)
deleteANote()
expectToSeeLabel("No notes")
}
deleteANote函式如下:
func deleteANote() {
let noteTableView = tester().waitForViewWithAccessibilityLabel("Note - TableView") as! UITableView
let indexPath = NSIndexPath(forRow: 0, inSection: 0)
tester().swipeRowAtIndexPath(indexPath, inTableView: noteTableView, inDirection: .Left)
tapButton("Delete")
}
結論
UI測試雖然簡單,卻可以讓我們獲得很多益處。
雖然它可能需要幾天去熟悉KIF,以及如何實作accessibility labels,但是在這之後,你會發現已經無法停止使用它。
你的UI測試將會覆蓋這個app所有的使用情境,未來即使你在程式碼中做了一些更改,只要測試仍能通過,即可確保app順利運行。
我發現唯一的缺點是它需要花時間去跑整個UI測試,尤其是你的應用程式越長越大以後,針對這個做筆記app來說,它花了我大約80秒去跑程式,而我是使用較老舊的hackintosh,且搭配的是Intel Core i3。
面對現實世界中較大的應用程式,測試可能會花更多的時間,從數分鐘到數小時,但好消息是,我們可以將跑測試的任務委任給另外一個服務,叫做Continuous Integration,它值得花另外一篇文章去單獨介紹。
如果讀者有任何關於UI測試的問題,並不要客氣踴躍於下方提出,期待可以獲得你的回應,感謝閱讀並預祝有美好的一天。
FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS