Swift 程式語言

iOS開發者指南:如何使用自動化UI測試

iOS開發者指南:如何使用自動化UI測試
iOS開發者指南:如何使用自動化UI測試
In: Swift 程式語言

你可能先前已經聽過自動化測試,尤其是在討論軟體品質的相關議題時,我們往往都會談論到自動化測試這個名詞。如果你不幫自己的專案寫任何的測試,可能會讓你遇上大麻煩,就算當下你感覺不到,但是長期來看,它將會累積成為很龐大的技術債務。

確實如此。

專案如果沒有寫測試,當越來越多開發者參與其中,並且隨著這個專案變得更大更複雜以後,要維護它幾乎是不可能的任務,當你未來更動到code,將會發現運作時出現問題,而且甚至是當老闆站在你桌子前面開始為了這個bug大聲斥責時,才會發現這個問題,我相信你對這個情境很熟悉,對吧。

所以,開發者最好要去了解如何使用測試,這樣一來,將得以改善你專案的品質,並且讓自己成為更優質的軟體工程師。

在iOS當中,有兩種類型的測試:

  • Unit test(單元測試):
    • 在class裡面測試某一個特定的動作。
    • 請確保這個動作在該類別運行時是獨立作業的。
  • UI test(介面測試):
    • 它也被稱為整合測試。
    • 用來測試在app運行時,使用者的每個動作是否與預期相同。
    • 確保全部的類別在作業時都妥善配合在一起。

上述兩種測試都一樣重要。

如果你只有寫unit tests,但完全忽略了UI tests,你將會陷入下列情境中:

unittest-integrationtest

如你所見,就像上圖的兩扇窗戶將無法順利配對運作,當把它們放在一起,糗事發生了,你可以看到畫面中的男子面對這個情況如此無奈。😉

UI tests是相當簡單的,甚至比unit tests更簡單。

今天我們將要學習一些非常基本的UI tests,讀者將可以從頭到尾看到一個功能齊全的app實作過程。

我們將實作什麼類型的應用程式呢?

它會是一個簡單的做筆記app,並擁有下列這些功能:

  • 透過使用者名稱與密碼進行登入。
  • 檢視已紀錄的一系列筆記。
  • 添加新的筆記。
  • 更新既有的筆記。
  • 刪除一個筆記。

下列為一個GIF圖檔,它將會顯示這個app實作出的全部功能:

note-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裡面?

這邊有兩個方式提供我們使用:

  1. 使用Storyboard:
  • 打開Storyboard.
  • 點擊一個你置放在view裡面的accessbility label
  • 選點Identity Inspector這個tab。

al-storyboard-1

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

al-storyboard-2

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

al-tableview

基本上,我們必須去設定key path,讓它能與UITableView的屬性配對,當程式運行時,它將讀取key path裡面的值,並設定相對應的屬性。

另外一個值得注意的是:UIButton或者UILabel,它將會有預設的accessibility label,且與text屬性相同,假設你有一個按鈕,其中的text為“click me”,那它的accessibility label同樣也是“click me”,你不需要重新去設定一次。

  1. 使用程式碼:

如果你在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,增加對它的了解。

Editor’s note:如果你無法compile這個專案,請先刪除SimpleNoteTakingApp.xcworkspace,並且重新”pod install”。

進行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檔案,名稱可以隨便取。

create-objc-file

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

add-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。

模擬器將會彈出並且很神奇的自動按照你指定步驟運行。

first-ui-test

測試應該會順利通過。(因為我們已經將有所有功能實作出來)

這邊我們進行一些程式碼重構(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 {

}

請讓LoginTestsHomeTest都繼承這個類別。

// 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
}

fillInNoteTitlefillInNoteBody相當簡單,我們只需要在欄位中填入一些文字。

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 tests)

如果讀者有任何關於UI測試的問題,並不要客氣踴躍於下方提出,期待可以獲得你的回應,感謝閱讀並預祝有美好的一天。

譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校ALPHA Camp畢業後,積極投入iOS程式開發,目前任職於國內電商公司。聯絡方式:電郵[email protected]

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

原文A Beginner’s Guide to Automated UI Testing in iOS

作者
Hoang Tran
Hoang Tran 是來自越南的一位資深iOS開發者。從non-ARC時期開始參與iOS開發工作,並花上大量時間和心血以完成Test-Driven Development 方式。 最近Hoang沉迷於 Swift的開發,日常亦熱愛寫作,當中大部份的文章都是關於iOS測試。歡迎瀏覽Hoang的部落格: http://hoangtran.me/
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。