你可能先前已經聽過自動化測試,尤其是在討論軟體品質的相關議題時,我們往往都會談論到自動化測試這個名詞。如果你不幫自己的專案寫任何的測試,可能會讓你遇上大麻煩,就算當下你感覺不到,但是長期來看,它將會累積成為很龐大的技術債務。
確實如此。
專案如果沒有寫測試,當越來越多開發者參與其中,並且隨著這個專案變得更大更複雜以後,要維護它幾乎是不可能的任務,當你未來更動到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