UI

利用 EarlGrey 做 UI Test 強化你的 UI 測試流程

利用 EarlGrey 做 UI Test 強化你的 UI 測試流程
利用 EarlGrey 做 UI Test 強化你的 UI 測試流程
In: UI

在軟體的開發過程中,我們一定會需要測試我們做出來的東西是不是運作正常。以手機開發為例,我們通常都是咻咻咻地寫好一堆程式之後,把它丟到手機或模擬器上面執行,然後東點一點西點一點,看看是不是一切都運作正常。這樣的流程雖然運作正常,但是卻非常花時間,而且很容易漏掉該測試的項目。如果要簡化這個流程,你就會需要電腦來幫你做自動化測試 (Test Automation)。

自動化測試有分很多類型,目前大家最常使用的分類法是用 Mike Cohn 所提出來的測試金字塔 (Test Pyramid),它根據自動化測試的細分程度,由下往上排成一個金字塔形狀(如下圖)。最上面的是 UI Test,也就是讓電腦直接操作 UI 並且根據 UI 上的狀況做判斷,整合度最高、最貼近使用者看到的樣子,但如果出錯的話,你沒辦法馬上得知出錯的是哪個環節。最底下的是 Unit Test ,就是針對程式裡面的最小單元做測試,它最容易被實現、並且每一個單元的測試都是獨立運作的,所以你可以得到細分到某個 function 的結果。中間的是 Integration Test,就是把元件一個一個組裝起來之後做整合性的測試,細分度介於上下兩個中間。

test-automation

(圖片來源:Testing Your Apps in Xcode – Apple

以一個線上商店 app 為例,Unit Test 就是測試一些獨立邏輯,像是計算折價的公式有沒有錯、幣值轉換是不是正確等等。而 Integartion Test 可以是資料有沒有正確地存入 Core Data、能不能正確地從 User Default 讀出設定值等等。而 UI Test 就是電腦幫你確認點擊了商品按鈕要跳出對話框、滑動頁面之後搜尋框要消失等等。

而對於跟介面高度相關的手機開發來說,UI Test 是一種非常好用的測試方法。一般來說,我們可以透過各種 Unit Test,來確保每個單元的邏輯運作都是正確的,但是當我們把一塊一塊的邏輯單元拼起來組裝成 UI 之後,很有可能你在 UI 上面看到的結果完全不是你想像的那樣 😩。雖然單元運作都很正確,但是因為把這些單元整合到 UI 上也需要各種商業邏輯的判斷,加上單元跟單元之間的互動也會需要整合邏輯的介入,所以光是 Unit Test 不足以確保我們 app 的品質。有了 UI Test,我們的測試就可以非常接近實際使用的場景,我們測試的目標是整合後的結果,也是最後使用者會看到的樣子。

XCTest 與 black-box testing

讓我們把鏡頭切回到 iOS/macOS 開發,Apple 提供了 XCTest 這個 framework 來幫助我們做 Unit Test(底下簡稱為 XCUnitTest)跟 UI Test(底下簡稱為 XCUITest)。如果你有撰寫過 XCUITest,你會發現 XCUITest 使用了眾多自動化測試技巧中的其中一種: black-box testing 。Black-box testing 指的是,對於測試程式來說,我們的 app 是一個完全的黑盒子,測試的程式就只能針對裝置上的 UI 來做操作跟判斷。你可以想像擁有一台機器人直接用機械手臂點按 app,並且用它的機械雙眼來判定 UI 上的結果是正確還是錯誤的,但這個機器人完全不知道這個 app 背後是怎樣運作的。

一個簡單的 XCUITest 寫起來會像這樣:

let app = XCUIApplication()

// 打開 app
app.launch()

// 點擊 `accessibilityIdentifier` 為 "button" 的按鈕
app.buttons["button"].tap()

// 驗證 id 為 "welcomeView" 的 view 是不是有出現在螢幕上
XCTAssertTrue(app.otherElements["welcomeView"].exists)

你可以看到整段程式碼都沒有使用到任何 UIKit 的功能,也沒辦法使用 app 自己定義的型別,對這隻程式來說,它就像是一般使用者在手機上執行這個 App 一樣,完全不了解 App 裡面的運作邏輯,只能透過 UI 來跟 App 做互動,所以我們說 XCUITest 是一種 black-box testing。雖然 black-box UI Test 寫起來比較容易(你甚至可以用 Xcode 的 Record UI Test 來幫你寫測試),並且也不需要知道 app 的程式邏輯,但是這樣一來,因為你沒有辦法跟你的 app 程式互動,就難以做到像設定環境、depedency injection 等等。而且因為我們不知道 app 頁面的構成,也就沒辦法單獨把某個頁面叫出來做小規模的 UI Test。另外,完全的 UI Test 也導致維護測試程式的成本變高,你有可能因為修改了某個小小的流程就要重寫所有相關的 UI Tests。

White-box testing

相對於 black-box testing,white-box testing 則是另外一種也很受歡迎的測試方法,從字面上可以看得出來這是一個跟 black-box testing 相反的測試方法,在 white-box testing 裡面,測試程式是可以「知道」app 的程式邏輯的,也就是說測試程式會知道 app 有哪些類形、哪些 method、也可以「碰」到 app 本身,把測試工具透過 dependency injection 裝進被測試的 app 裡、呼叫某個 app 的 function 等等。

利用 white-box testing 的技巧,我們就可以做到小規模且獨立的 UI Test。我們可以只測試某一個特定頁面的 UI,並且使用一些預先準備好的資料簡化整個測試流程,也可以加快測試的速度。我們也可以避免 UI Test 彼此之間的互相干擾,提升測試程式的穩定度。

EarlGrey:簡單好用的 white-box testing framework

雖然 XCUITest 只能做到 black-box testing,但是還好我們還有其它的選擇!利用 Google 在 2016 年發佈的測試 framework:EarlGrey,我們就可以做到 white-box UI Test!

EarlGrey 是一個基於 XCUnitTest 的 UI Test framework。一般 XCUITest 在執行的時候,是同時跑兩個 process,一個是 App,另外一個跑測試程式(也就是我們的測試機器人,完全獨立運作在 app 之外):

XCUITest

而 XCUnitTest 則是把測試程式跟 App 跑在一起,所以測試程式可以直接跟 App 的程式做互動:

XCUnitTest

而 EarlGrey 就是利用 XCUnitTest 這樣的特點來做到 white-box testing 的。它主要的特色有:

  1. 因為是運作在 XCUnitTest 底下,所以可以輕易地做到 white-box testing
  2. 方便的同步支援 (synchronization):EarlGrey 預設會等待畫面中的 network request、animation、transition 等等,你可以不用指定要等待多少時間後才進行判定 (assert),EarlGrey 會根據畫面改變的狀態來決定要不要進行到下一行程式碼。

大綱

透過這篇文章,你可以了解到:

  • EarlGrey 的 Interaction API 與基本觀念
  • 一些實際情境的測試
  • 撰寫測試的小技巧
  • 如何利用 EarlGrey 撰寫 white-box UI Test

如果你有 XCTest 或寫測試的經驗的話,這篇文章讀起來應該就會很輕鬆,但如果沒有的話也沒關係,我們會盡量避開可能會搞混或誤解的專有名詞。

接下來我們就要來手把手(也可以不用),一起學習如何使用 EarlGrey 來強化你的 UI 測試流程!

EarlGrey 的安裝

首先,在進行安裝之前,先確定你的 project 已經設定好了 Unit Test target,你可以在開始一個 project 之前,先勾選 Unit Test 支援:

earlgrey-installation-1

或者直接在 project 設定中新增一個 target:

earlgrey-installation-2

並且選擇 Unit Test Bundle:

earlgrey-installation-3

如果你是後來新增 target 的,記得到 Manage Schemes -> Test 確認一下我們的 test target 是不是有被加到 scheme 裡,如果沒有的話點一下右下角的 ”+” 號,再把我們剛剛新增的 test target 加進去就好了:

earlgrey-installation-4

設定完 test target,我們還需要來安裝 EarlGrey 的輔助程式:

gem install earlgrey

最後我們就可以直接透過 Cocoapods 來安裝 EarlGrey:

  target 'EarlGreyDemoTests' do  // 這裡要換成你 test target 的名字
    use_frameworks!
    inherit! :search_paths

    pod 'EarlGrey'
  end

要記得 pod 'EarlGrey' 是要加到 test target 裡面,並且要把 use_frameworks! 打開。

執行完 pod install 之後就安裝完了!

安裝完之後來簡單測試一下,首先先在 File -> New -> File… 新增一個 Unit Test 的檔案:

earlgrey-installation-5

加入一個非常簡單的測試:

    func testExample() throws {
        EarlGrey.selectElement(with: grey_keyWindow())
                .assert(grey_sufficientlyVisible())
    }

這個測試會檢查目前的 keyWindow 是不是有在畫面上。接著按下 ⌘ + U 執行測試,你會看到 App 在裝置或 simulator 上被喚醒,並且出現主畫面,這時候 Xcode 應該就會回傳 Test Success,這就表示這個測試 ok 了!

你可以在 EarlGrey/install-and-run 裡面找到更詳細的安裝說明。

接著,我們要來看看怎麼樣使用 EarlGrey!

簡單地來介紹一下 EarlGrey 吧!

EarlGrey 主要提供兩種 API 讓我們使用:

  • Interaction:讓測試程式跟 app UI 進行互動的 API
  • Synchronization:控制測試程式,讓它能夠等待 app 的執行

下面我們主要會介紹 Interaction API,關於 Synchronization 的設定可以在更底下的實作範例看到,或者參考官方的 Synchronization 文件。

Interaction APIs

EarlGrey 跟 XCUITest 或其它的 UI Test framework 一樣,主要的測試流程是透過類似下面的圖來完成的:

earlgrey-test-flow
  1. 測試程式會有一個想要操作的 UI 元件,可能是一個按鈕、或是文字為 “Title” 的 UILabel 等等。為了要找到我們想要操作的 UI 元件,我們要列出這些 UI 元件的條件,讓 EarlGrey 能夠找到它。這些條件我們稱做是 Matcher。Matcher 可以指定型別、accessibilityIdentifier、元件的文字內容等等,透過組合一個到多個的 matcher,EarlGrey 就可以輕易地找到我們想要操作的元件。
  2. 有了上面的條件 (matcher) 後,負責根據這些條件在 UI 上搜尋目標的物件我們稱為 Selector。Selector 會在目前 app 上的 UI 元件中,找出所有符合我們指定條件的所有元件。
  3. Selector 如果有找到目標的元件 ,就會把 reference 回傳。要注意這邊可能會找到零個或多個元件,如果沒有符合條件的目標,就會噴 exception。
  4. 現在我們測試程式已經有了目標的 reference,我們就可以利用 EarlGrey 的 Action API 來對元件進行操作。
  5. 在下了操作指令之後,我們要來看看 app 是不是有針對剛剛的 action 做出反應,EarlGrey 提供了許多 Assert 的方法來做到判定這件事。

如果你熟悉 XCUITest 的話,這些流程應該都是完全一樣的,差別只在兩邊提供的 API 不太一樣而已。再複習一下,剛剛我們看到的 API,主要有分四種:

  • Matcher:搜尋 UI 元件的條件
  • Selector:尋找並且回傳 reference
  • Action:執行 UI 操作
  • Assert:判定條件是否有符合

接著,我們來一個一個看看裡面有哪些 API 可以使用吧!

Matcher & Selector

想像一下,我們現在想要針對 app 上面的 “Submit” 按紐做操作,我們要怎麼下條件給 selector?我們有幾種方法可以做到這件事:

  • 尋找 title 是 “Submit” 的按紐
EarlGrey.selectElement(with: grey_buttonTitle("Submit"))

這個非常直觀,grey_buttonTitle() 代表的是所有 title 為 “Submit” 的按鈕。selectElement() 就是我們的 selector,負責根據提供的 matcher 來做搜尋。

  • 類型是 UIButton ,並且出現在順序 n 的物件(假設有好幾個按紐在畫面上)
EarlGrey.selectElement(with: grey_kindOfClass(UIButton.self))
        .atIndex(n)

利用 grey_kindOfClass(),我們可以指定想搜尋的元件型別,因為 matcher 有可能會找到多個元件,利用 atIndex() ,我們就可以直接抓出我們想要的物件。

  • 指定 accessibilityIdentifier

accessibilityIdentifier 是用來定位 accessibility 相關元素的標籤,在 XCUITest 之中也被廣泛地拿來當做定位被測試元件的工具。利用這個標籤,我們就可以更容易地直接找到我們想測試的元件。

要做到這點,我們要先在我們的 app 針對某個 UI 元件下好標籤:

submitBtn.accessibilityIdentifier = "submit_button"

然後在測試 code 裡面,我們就可以這樣做:

EarlGrey.selectElement(with: grey_accessibilityID("submit_button"))

一般來說,我們會推薦利用 accessibilityIdentifier 來定位物件,這樣一來是你的 matcher 不會因為 UI 改個位置或改個 title 而失效,再來其它的 accessibility 功能也可以利用這些標籤來定位。

  • 找到放在 ButtonContainer 裡面的 UIButton
EarlGrey.selectElement(with: grey_kindOfClass(UIButton.self))
        .inRoot(grey_kindOfClass(ButtonContainer.self))

如果一開始的 selector 找到一個以上的結果,可以用 inRoot 限定我們想找的元件 A (UIButton) 要屬於元件 B (ButtonContainer) 的 subview。

  • 我條件很多,但我全都要!

EarlGrey 提供了幾個方便的 API 滿足你所有的神秘堅持:

EarlGrey.selectElement(with:
    grey_allOf([
        grey_buttonTitle("Submit"),
        grey_sufficientlyVisible()
    ])
)

grey_allOf() 能夠把不同的 matcher 組合在一起。上面這段程式的意思是「取得所有 title 為 “Submit” 的按鈕,並且這個東西要是 UI 上可見的」。

另外比較少用、但能夠幫你的 matcher 增添不少色彩的,還有 grey_not()grey_anyOf()。望 code 生義,想必你已經猜到它們的功能了:

grey_not(grey_kindOfClass(UITextField.self))

grey_anyOf([
    grey_buttonTitle("Submit"),
    grey_kindOfClass(UIButton.self)
])
  • Custom Matcher

最後如果上面的 matcher 都無法滿足你的條件,EarlGrey 也提供了可以產生完全客制化 matcher 的 GREYElementMatcherBlock 供使用:

let enabledButtonMatcher = GREYElementMatcherBlock(matchesBlock: { view -> Bool in
    if let view = view as? UIButton {
        return view.isEnabled // 回傳 true 代表條件符合
    }
    return false
}, descriptionBlock: { description in
    description.append(of: "Enabled Button")
})
EarlGrey.selectElement(with: enabledButtonMatcher)

上面的 matcher 代表的是找到所有 isEnabled 為 true 的 UIButton

好的,搞定了 matcher 跟 selector,找好了我們的測試目標之後,接下來我們就要來透過程式直接操作我們的 UI 了!

Action

EarlGrey 提供了很多 API 讓你做 UI 操作,所有的操作都可以透過 perform() 這個 method 來觸發。直接來看看例子:

EarlGrey.selectElement(with: grey_buttonTitle("Submit"))
        .perform(grey_tap())

上面這段程式的意思就是,在我們找到的這個按鈕上面點一下 (grey_tap()),是不是非常直覺!其它常用的 action 還有:

  • grey_doubleTap()
  • grey_longPress()
  • grey_doubleTap()
  • grey_longPress()
  • grey_scrollInDirection(direction, amount)
  • grey_swipeFastInDirection(direction)
  • grey_swipeSlowInDirection(direction)
  • grey_typeText(…)

這些都可以直接從 method 的名稱看出它們的功能。另外還有一個特別的 action:

EarlGrey.selectElement(with: grey_accessibilityID("cell_5"))
        .usingSearch(grey_scrollInDirection(.down, 100),
              onElementWith: grey_accessibilityID("tableView"))

上面的 usingSearch 接收兩個參數,分別是 actionelement。這個 method 會一直針對 elementaction 指定的動作,直到找到我們原本 selector 要找的元件出現為止。以上面的程式為例,測試程式會一直往下滾動某個 tableView,一直到 accessibilityIdentifier 為 “cell_5” 的元件出現為止。這是一個非常實用的 action,可以運用在需要操作才能顯現的 UI 元件上。

最後,你也可以製作專屬於你的 action:

let tripleTouch = GREYActionBlock(name: "tripleTouch", constraints: nil) { (view, error) -> Bool in
    // 針對找到的 UI 元件做我們指定的動作
    if let btn = view as? UIButton {
        btn.sendActions(for: .touchUpInside)
        btn.sendActions(for: .touchUpInside)
        btn.sendActions(for: .touchUpInside)
        return true
    }
    error = ... // 可以寫入 error ,讓 EralGrey 可以把錯誤資訊顯示出來
    return false
}
EarlGrey.selectElement(with: grey_accessibilityID("submitButton"))
        .perform(tripleTouch)

這是一個對某個目標元件點擊三下的 action。(對,不太實用)

Assert

最後在對 UI 元件做完操作後,我們需要來驗證一下 UI 的反應或呈現是不是正確的,這個步驟我們把它稱為 assert。直接看個例子:

func testCancelButtonHiddenWhenBeingTapped() throws {
      EarlGrey.selectElement(with: grey_buttonTitle("Cancel"))
          .perform(grey_tap())
          .assert(grey_notVisible())
}

這段測試的意思是:選取畫面中 title 為 “Cancel” 的按鈕,點擊它,然後測試它是不是有消失在螢幕之中 (grey_notVisible())。

上面的 selector 找到 button 元件之後,後面我們呼叫了 assert() method,表示想判斷剛剛找到的元件的狀態是否有符合我們的預期。assert() 的參數是一個 matcher,而 grey_notVisible() 這個 matcher 就是代表元件是看不到或是根本不存在的。

另外,我們也可以像一般 Unit Test 一樣,直接呼叫 assert macro,這邊有幾個可以使用的例子:

  • GREYAssertEqual(left, right, reason):判定 left 跟 right 是不是相等
  • GREYAssertTrue(condition, reason):判定 condition 是不是 true
  • GREYAssertNil(object, reason):判定 object 是不是 nil

雖然你也可以用 XCTest 的 XCAssert 達到一樣的判定效果,但是利用 EarlGrey 的 assert macro 的話,你可以確保出錯的時候,錯誤控制都統一由 EarlGrey 來操作,而不是直接跳出測試流程。

現在我們已經簡單的介紹了 EarlGrey 的 Interaction API,雖然 API 數量不少,但是只要把握好我們的測試流程:

提供 matcher 讓 selector 選取物件 -> 針對物件進行 Action -> 在動作之後進行 Assert

有了這樣的概念之後,要撰寫測試就是一件輕而易舉的事!

除了剛剛介紹的幾個 API 之外,你可以在官方文件找到更多的 API:

另外官方也有提供非常好用的 cheatsheet,非常推薦直接準備一份在手邊,如果一時想不起來 API 的名稱,或者想做到某個功能但不確定要呼叫哪個 API,就可以直接查這個 cheatsheet!

現在了解了 EarlGrey 的基本運作方式之後,讓我們來看看真實世界的範例吧!

幫搜尋頁面做 UI Test

我們的測試目標是一個搜尋電影的 app,它的介面跟互動長這樣:

app-demo

這個 app 一打開就會停留在搜尋的頁面,一進到搜尋頁,就會顯示一個指示畫面(鍵盤 icon 加上 “Search by keyword” 字樣的 UILabel),告訴使用者可以輸入任意文字做搜尋。使用者點擊了輸入框之後,指示的畫面會消失,輸入框會往上移動,讓使用者取消的按鈕會出現(標題為 Cancel 的按鈕)。最後當使用者輸入文字之後,搜尋的結果會出現在輸入框的底下。

我們把這個頁面的功能切分成幾個待測試的項目:

  1. 當使用者第一次進到搜尋頁面,要顯示指示畫面,並且指示畫面要在搜尋框的上面
  2. 當使用者點擊輸入框,指示畫面要消失,並且要出現 Cancel 的按鈕
  3. 當使用者輸入文字後,搜尋結果要出現在輸入框的底下

接著我們來一個一個看看我們怎麼安排我們的 UI Test:

1. 當使用者第一次進到搜尋頁面,要顯示指示畫面,並且指示畫面要在搜尋框的上面

我們先幫我們頁面上的元素標好 accessibilityIdentifier

earlgrey-step-1

接著,我們就可以測試指示畫面是不是有顯示在畫面上了:

func testInintalState() throws {
    EarlGrey
        .selectElement(with: grey_accessibilityID("SearchView_initialView")) // 取得指示畫面的 reference
        .assert(grey_sufficientlyVisible()) // 判斷元件是不是可視的
}

不過,要判斷指示畫面是不是有在輸入框的上面,我們需要導入一個新的 API:GREYLayoutConstraint

描述特定 layout constraint 的工具: GREYLayoutConstraint

GREYLayoutConstraint 是 EarlGrey 提供用來描述某個元件的 layout constraint 的工具。它可以用來代表元件跟元件之間的相對位置,或者某個元件的高度或寬度等等。我們先直接來看看它的用法:

let belowConstraint = 
    GREYLayoutConstraint(attribute: .top, 
                         relatedBy: .greaterThanOrEqual, 
                         toReferenceAttribute: .bottom, 
                         multiplier: 1, 
                         constant: 0)

上面這個 initializer 描述了:目標元件的頂端 (top) 跟相對元件的底端 (bottom) 要大於或等於 (greaterThanOrEqual) 0,也就是我們想要測試的目標要在某個相對元件的底下。用圖來看的話,就是下圖中間虛線的 layout constraint:

layout-constraint

不過我們要怎麼指定 base 跟 reference 分別是那兩個元件呢?我們來看看:

let initialViewMatcher = grey_accessibilityID("SearchView_initialView")
let textfieldMatcher = grey_accessibilityID("SearchView_textfield")

EarlGrey
    .selectElement(with: textfieldMatcher)
    .assert(grey_layout([belowConstraint], initialViewMatcher))

從上面可以看到,我們用 selector 選擇了 “SearchView_textfield” 這個元件,這個就是我們的 base。grey_layout 是一個幫助你產生 matcher 的 method,它需要輸入兩個參數,前面的參數要放入 [GREYLayoutConstraint],用來代表我們想要描述的 layout constraint。這裡我們放入 belowConstraint,代表我們希望判斷 base 有沒有在「某個元件」的底下。grey_layout 的第二個參數就是「某個元件」,也就是上圖的 reference,你要傳入代表該物件的 matcher,在這邊是 “SearchView_initialView”。

整段 code 的意思就是:判定 “SearchView_textfield” 是不是在 “SearchView_initialView” 的底下。

你只要記得 GREYLayoutConstraint 就是要拿來描述某個 NSLayoutConstraint,你就可以很輕鬆地了解這個 API 的設計。你可以看到 GREYLayoutConstraint 的 initializer API 跟 NSLayoutConstraint 的 API 是類似的:

// NSLayoutConstraint
init(item view1: Any, 
     attribute attr1: NSLayoutConstraint.Attribute, 
     relatedBy relation: NSLayoutConstraint.Relation, 
     toItem view2: Any?, 
     attribute attr2: NSLayoutConstraint.Attribute, 
     multiplier: CGFloat, 
     constant c: CGFloat)

// GREYLayoutConstraint
init(attribute: GREYLayoutAttribute, 
     relatedBy relation: GREYLayoutRelation, 
     toReferenceAttribute referenceAttribute: GREYLayoutAttribute,
     multiplier: CGFloat, 
     constant: CGFloat)

差別只有在 GREYLayoutConstraint 沒有 view1view2 這兩個參數,這個 view1 就是我們的 base,也就是第一個 selector 回傳的元件。而 view2 就是傳入 grey_layout 的第二個參數,也就是上圖的 reference。

我們來統整一下我們的第一個測試 code:

func testInintalState() throws {
let initialViewMatcher = grey_accessibilityID("SearchView_initialView")
let textfieldMatcher = grey_accessibilityID("SearchView_textfield")
EarlGrey
    .selectElement(with: grey_accessibilityID(initialViewMatcher)) // 取得指示畫面的 reference
    .assert(grey_sufficientlyVisible()) // 判斷元件是不是可視的

// 描述 "base 要在 reference 的下面"
let belowConstraint = GREYLayoutConstraint(attribute: .top, relatedBy: .greaterThanOrEqual, toReferenceAttribute: .bottom, multiplier: 1, constant: 0)

// 判斷螢幕上的畫面有沒有符合描述的 layout constraint 
EarlGrey
    .selectElement(with: textfieldMatcher)
    .assert(grey_layout([belowConstraint], grey_accessibilityID(initialViewMatcher)))
}

接著來進行第二個測試。

2. 當使用者點擊輸入框,指示畫面要消失,並且要出現 Cancel 的按鈕

第二個測試很簡單,一樣我們先來設定一下 accessibilityIdentifier

earlgrey-step-2

組織測試的小技巧:Arrange-Act-Assert

我們可以把這個測試分解成兩個部分:

  • act:使用者點擊輸入框
  • assert:
    • 指示畫面要消失
    • Cancel 按紐要出現

這邊我們用了一個常見的撰寫測試技巧:Arrange-Act-Assert (AAA),先安排好我們要的資料 (arrange),然後進行某個動作 (act),最後判斷動作後的結果是不是正確 (assert),在我們的範例中因為一打開 app 畫面就是搜尋頁面,所以暫時不需要另外做 arrange。

依據上面的架構,我們的測試 code 就可以寫成這樣:

func testTapToTransitToSearchState() throws {
    // act
    EarlGrey.selectElement(with: grey_accessibilityID("SearchView_textfield"))
        .perform(grey_tap()) // 點擊輸入框

    // assert 1
    EarlGrey.selectElement(with: grey_accessibilityID("SearchView_initialView"))
        .assert(grey_notVisible()) // 指示畫面要消失

    // assert 2
    EarlGrey.selectElement(with: grey_accessibilityID("SearchView_cancelButton"))
        .assert(grey_sufficientlyVisible()) // Cancel 的鈕要出現
}

最後來看一個特別的例子。

3. 當使用者輸入文字後,搜尋結果要出現在輸入框的底下

老樣子,先定義好 accessibilityIdentifier

earlgrey-step-3

注:這個 identifier 是被標在 tableView 上的

看起來非常簡單!!照著我們剛剛學到的這樣做就對了:

func testResultListShownWhenTypingToSearch() throws {
    // act
    EarlGrey.selectElement(with: grey_accessibilityID("SearchView_textfield"))
            .perform(grey_typeText("matrix"))


    // assert 

    // 因為結果會被鍵盤擋到,所以放寬到只要有 20% 看得到就好
    EarlGrey.selectElement(with: grey_accessibilityID("SearchView_resultList"))
            .assert(grey_minimumVisiblePercent(0.2)) 
}

當我們開心地點下 ⌘ + U 測試,會發現 EarlGrey 殘忍地噴了一個錯誤:

Exception Name: NoMatchingElementException
Exception Reason: Cannot find UI Element.

人生果然沒有這麼順遂!看起來是找不到 “SearchView_resultList” 這個元件,這是為甚麼呢?我們來看看測試時的畫面:

test-screen

這個測試在打完字之後就直接檢查 “SearchView_resultList”,並且直接噴錯誤了,而 UI 上 “SearchView_resultList” 根本還沒有出現!不過,EarlGrey 理論上應該會等待搜尋的 request 回傳結果之後才進行下一步才對,看起來 EarlGrey 完全沒有等待就直接判定了。

我們來看看一般的使用情境,看能不能推敲出一些線索:

test-problem

看起來情況已經相當明朗了!在原本的搜尋功能中,為了減少 request 的次數,打完字之後會等待 500ms,如果使用者沒有繼續打字,我們才會把目前文字框的文字送到 server 做搜尋!也因為這樣,畫面上既沒有未完成的動畫,也沒有 request 被發出去,EarlGrey 會判斷目前所有非同步的工作都已經結束,可以進行下一步,也就導致了上面找不到元件的錯誤。

遇到這樣的狀況,我們該怎樣解決呢?這個時候我們就需要透過 EarlGrey 的 Synchronization API 的幫助!

來組織一下我們遇到的問題,目前我們希望在打完文字之後,EarlGrey 能夠等待一段時間,等到結果出現之後再做 assertion。

在這裡,我們要用 GREYCondition 來做等待的部分。GREYCondition 在初始化的時候,需要輸入一個回傳 bool 的 closure,EarlGrey 在等待的過程中會一直呼叫這個 closue,如果這個 closue 回傳 true,EarlGrey 就會結束等待,執行下一行指令。我們來看看 code 要怎麼寫:

let condition = GREYCondition(name: "waiting for result") { () -> Bool in
    var error: NSError?

    // 檢查 UI 元件,並且把錯誤丟到 `error` 

    return error == nil
}

condition.wait(withTimeout: 3)

// 上面的 closue 一回傳 true,這邊就會馬上被執行
// 不然就要等 3 秒後才會被執行

了解了 GREYCondition 的用法之後,我們就可以把原本的測試改寫成這樣:

func testResultListShownWhenTypingToSearch() throws {
    EarlGrey.selectElement(with: grey_accessibilityID("SearchView_textfield"))
            .perform(grey_typeText("matrix"))

    let condition = GREYCondition(name: "waiting for result") { () -> Bool in
        var error: NSError? = nil

        // assert result list 是不是有出現
        EarlGrey.selectElement(with: grey_accessibilityID("SearchView_resultList"))
                .assert(grey_minimumVisiblePercent(0.2), error: &error)

        // 只有上面的 assertion 成功這邊才會回傳 true
        return error == nil
    }

    // 在三秒內,上面的 closure 會一直被呼叫,直到時間到或者 closure 回傳 true
    let finished = condition.wait(withTimeout: 3)

    // 如果 3 秒內都沒看到 result list,就在這邊噴錯誤
    GREYAssertTrue(finished, reason: "Condition timeout")
}

這樣我們的測試就會成功啦!上面的測試執行結果就會長這樣:

test-demo

人生又再次順遂了起來!

以上這些就是簡單的 UI Test 範例,不過這個 app 目前只有一個頁面,一打開 app 就是我們想要測試的目標畫面。很不幸的是,人生果然還是沒有那麼順遂(才順遂不到五秒)!一般 app 應該都會有一個以上的頁面,包括登入/註冊頁面、列表頁面、設定頁面等等,如果我們要測試的頁面不是在最一開始的畫面,根據 Arrange-Act-Assert,我們需要在 Arrange 這個階段安排一些頁面點擊,把 app 移動到我們想要測試的頁面,並且在測試完之後,再把 app 復原。

這樣子的方法雖然可行,不過寫起來卻相當麻煩,而且如果目標頁面需要讀取資料或被藏在很深的地方,整個 UI Test 跑起來可能會相當緩慢。想像一下你想要測試從我的購物車裡面移除某個商品,你的 arrange 就需要包含:登入使用者 -> 點開商品頁面 -> 點選某個商品加入購物車 -> 點開我的頁面 -> 移除某個商品 💦。

看起來我們需要一個辦法來跑小規模的 UI Test!

利用 white-box testing 技巧進行獨立的 UI Test

還記得 white-box testing 嗎?在 white-box testing 的環境底下,我們的測試程式是可以「看到」app 程式的。利用這樣的特性,我們可以在 Arrange 這個階段,直接修改我們的 app 來設定好要測試的環境,而不用像 XCUITest 那樣只能在介面上互動。

假設我們剛剛要測試的 SerachView 搜尋頁面並不是 app 打開時的第一個頁面,在測試搜尋頁面之前,我們就需要「做」出一個搜尋頁面讓測試 app 來操作。具體而言我們要怎麼做呢?首先我們要先取得目前 app 最上層的 viewController:

func getTopViewController() throws -> UIViewController  {
    guard let rootViewController = UIApplication.shared.windows.last(where: { $0.isKeyWindow })?.rootViewController else {
        fatalError("Cannot get top view controller") // 或者 `throw` 一個 Error 物件
    }
    return topViewController
}

利用這個 method,我們就可以在測試 code 裡面取得最上層的 viewController,然後在這個 viewController 上面放上我們產生的 SearchView,讓 EarlGrey 去做測試。我們來用 AAA 方法來組織一個簡單的 UI Test:

  • Arrange:一個初始狀態的 SearchView
  • Act:使用者點擊了文字框
  • Assert:Cancel 按鈕要出現

聽說工程師看 code 比看中文還快,讓我們來用工程師的語言把上面的話再說一次:

func testInintalState() throws {
    // ==== Arrange ====
    let topViewController = try getTopViewController()
    let searchViewController = UIHostingController(rootView: SearchView()) // 以 SwiftUI 為例

    // 這邊需要根據你測試中的 viewController 的 presentation style 做調整
    searchViewController.modalPresentationStyle = .overFullScreen

    // 在畫面上呈現待測試的 viewController
    topViewController.present(searchViewController)

    // ==== Action ====
    EarlGrey.selectElement(with: grey_accessibilityID(SearchView.accessibilityId(of: .textfield)))
            .perform(grey_tap())

    // ==== Assert ====
    EarlGrey.selectElement(with: grey_accessibilityID(SearchView.accessibilityId(of: .cancelButton)))
            .assert(grey_sufficientlyVisible())
}

在 Arrange 的階段我們產生了一個待測試的 viewController (searchViewController),並且把它顯示在畫面之中 (topViewController 之上),從這邊開始 EarlGrey 所看到的畫面就會是 SearchView,而不是其它不相關的頁面。

以上這只是 white-box testing 的其中一種例子,如果你的 SearchView 的資料或者其它 depedency 是可以被替換的,你也可以直接在 Arrange 的階段做這件事,舉例來說:

    let testData = getTestMovieList()
    let searchViewController = UIHostingController(rootView: SearchView(searchResult: testData))

就可以把一開始的資料替換成我們預先抓好的測試資料。

利用這樣的技巧,我們可以只測試某個特定的頁面,或甚至某個特定的 view,這樣的好處是在 Arrange 的時候,可以用比較簡單的方式設定好我們的環境,而且小規模的 UI Test 比較不會受到修改流程或者其它頁面規格的影響,測試程式也就不需要為了不相關的功能而做修改。

不過要注意的是,在 Arrange 階段安排的環境不能夠影響我們測試的目標,比方說如果我們想測試 Cancel 按鈕是不是有出現在畫面上,我們就不能在 Arrange 階段顯示或隱藏 Cancel 的按鈕。出題的人當然不能先把答案填好!

最後我們可以把一些 code 封裝成 helper 方便其它的 UI Test 使用:

struct TestHelper {
    private static func getTopViewController() throws -> UIViewController  {
        guard let topViewController = UIApplication.shared.windows.last(where: { $0.isKeyWindow })?.rootViewController else {
            fatalError()
        }
        return topViewController
    }

    static func presentView<T: View>(view: T) throws {
        let vc = UIHostingController(rootView: view)
        vc.modalPresentationStyle = .overFullScreen
        try getTopViewController().present(vc, animated: false, completion: nil)
    }
}

// 在你的 test code 裡就可以直接呼叫:
func testSearchViewInitalState() {
    try TestHelper.present(view: SearchView())  // initialize 且 present 一個 SearchView

    .....
}

是不是非常方便呢?

結論

讓我們來回想一下今天學到了哪些東西:

  • Automation Test 的種類與 white-box/black-box testing 的差異
  • EarlGrey 的幾個基本 API:Selector, Matcher, Action, Assertion
  • 組織 UI Test 的 Arrange-Act-Assert 方法
  • 如何利用 EarlGrey 的 white-box testing 特性做小規模的 UI Test

涵蓋的範圍很廣,沒辦法全部都記住也沒關係,只要大概知道個方向,實作的時候有問題再回來查閱就好囉!

這篇文章的目的不是希望你把所有 XCUITest 的 code 全部都改成 EarlGrey 來撰寫,也不是要說 EarlyGrey 就一定比 XCUITest 還要好用,還是要依照你的 project 屬性、成員的技術熟悉度、還有想要達成的任務目標來做決定。EarlGrey 提供了一個跟 XCUITest 很不一樣的測試方法,兩者大多時候都是可以並行,並且很有可能兩者都是需要的。了解到了各種測試技巧之後,你就可以跟你的團隊一起基於當下的需求制定你們的測試流程跟方法,選用適當的工具,進而提升軟體的品質!

最後,EarlGrey 是基於 XCUnitTest 的測試 framework,Google 另外也推出了 EarlGrey2,一個基於 XCUITest 的測試 framework。雖然 EarlGrey2 是在 XCUITest 底下運行的,也就是說它是在 app 程式以外被執行的(可以參照上面的章節),但它同樣也支援 white-box testing,雖然目前還不是很穩定,但有興趣的一樣可以去看看!

參考資料

大多數的 API 資料都可以在官方文件上找到:EarlGrey/api.md at master · google/EarlGrey · GitHub

實作的方法變化非常多,官方有個用 Objective-C 寫成的範例,裡面有不少實用的方法可以參考:EarlGrey/Tests/Functional at earlgrey2 · google/EarlGrey · GitHub

作者
Huang ShihTing
I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。