[譯] 在 iOS 中使用 UITests 測試 Facebook 登入功能

little_xia發表於2018-09-06

[譯] 在 iOS 中使用 UITests 測試 Facebook 登入功能

圖片來源: 谷歌

今天我正試圖在我的應用程式上執行一些 UITest,它整合了 Facebook 登入功能。以下是我的一些筆記。

[譯] 在 iOS 中使用 UITests 測試 Facebook 登入功能

挑戰

  • 對我們來說,使用 Facebook 的挑戰主要在於, 它使用了 Safari controller,而我們主要處理 web view。從 iOS 9+ 開始,Facebook 決定使用 safari 取代 native facebook app 以此來避免應用間的切換。你可以在這裡閱讀詳細資訊 在iOS 9上為人們構建最佳的 Facebook 登入體驗
  • 它並沒有我們想要的 accessibilityIdentifier 或者 accessibilityLabel
  • webview 內容將來可能會發生變化 ?

建立一個 Facebook 測試使用者

幸運的是,您不必建立自己的 Facebook 使用者用於測試。Facebook 支援建立測試使用者,可以管理許可權和好友,非常方便

[譯] 在 iOS 中使用 UITests 測試 Facebook 登入功能

當我們建立測試使用者時,您還可以選擇不同語言。這將是 Safari Web 檢視中顯示的語言。我現在選擇的是 Norwegian ??

[譯] 在 iOS 中使用 UITests 測試 Facebook 登入功能

單擊登入按鈕並顯示 Facebook 登入

這裡我們使用預設的 FBSDKLoginButton

var showFacebookLoginFormButton: XCUIElement {
  return buttons["Continue with Facebook"]
}
複製程式碼

然後點選它

app.showFacebookLoginFormButton.tap()
複製程式碼

檢查登入狀態

當在 Safari 訪問 Facebook 表單時,使用者也許已經登入過,也許沒有。所以我們需要處理這兩種情況。所以我們需要處理這兩個場景。當使用者已經登入時,Facebook 會返回你已經登入OK 按鈕。

這裡的建議是新增斷點,然後使用 lldb 命令 po app.staticTextspo app.buttons,檢視當前斷點下的 UI 元素。

您可以檢查靜態文字,或只是點選 OK 按鈕

var isAlreadyLoggedInSafari: Bool {
  return buttons["OK"].exists || staticTexts["Du har allerede godkjent Blue Sea."].exists
}
複製程式碼

等待並重新整理

因為 Facebook 表單是一個 webview ,所以它的內容是有點動態的。並且 UITest 似乎會快取內容以便快速查詢,因此在檢查 staticTexts 之前,我們需要 waitrefresh the cache

app.clearCachedStaticTexts()
複製程式碼

這裡實現了 wait 功能

extension XCTestCase {
  func wait(for duration: TimeInterval) {
    let waitExpectation = expectation(description: "Waiting")

    let when = DispatchTime.now() + duration
    DispatchQueue.main.asyncAfter(deadline: when) {
      waitExpectation.fulfill()
    }

    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }
}
複製程式碼

等待元素出現

但更保險的方法是等待元素出現。對於 Facebook 登入表單來說,他們會在載入後顯示 Facebook 的標籤。所以我們應該等待這個元素出現

extension XCTestCase {
  /// Wait for element to appear
  func wait(for element: XCUIElement, timeout duration: TimeInterval) {
    let predicate = NSPredicate(format: "exists == true")
    let _ = expectation(for: predicate, evaluatedWith: element, handler: nil)

    // Here we don't need to call `waitExpectation.fulfill()`

    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }
}
複製程式碼

在對 Facebook 登入表單中的元素進行任何進一步檢查之前,請呼叫此方法

wait(for: app.staticTexts["Facebook"], timeout: 5)
複製程式碼

如果使用者已登入

登入後,我的應用程式會在主控制器中顯示一個地圖頁面。因此,我們需要簡單的測試一下,檢查該地圖是否存在

if app.isAlreadyLoggedInSafari {
  app.okButton.tap()

  handleLocationPermission()
  // Check for the map
  XCTAssertTrue(app.maps.element(boundBy: 0).exists)
}
複製程式碼

處理中斷

我們知道,當要顯示位置地圖時,Core Location 會傳送請求許可。所以我們也需要處理這種中斷。你需要確保在彈框彈出之前儘早呼叫它

fileprivate func handleLocationPermission() {
  addUIInterruptionMonitor(withDescription: "Location permission", handler: { alert in
    alert.buttons.element(boundBy: 1).tap()
    return true
  })
}
複製程式碼

還有一個問題,這個監視器不會被呼叫。所以解決方法是在彈框彈起時再次呼叫 app.tap()。 對我來說,我會在我的 ‘地圖’ 顯示1到2秒後呼叫 app.tap(),這是為了確保在顯示彈框之後再呼叫 app.tap()

更詳細的指南,請閱讀 #48

如果使用者未登入

在這種情況下,我們需要填寫郵箱賬戶和密碼。 您可以檢視下面的完整原始碼部分。當如果方法不起作用或者 po 命令並沒有列印出你需要的元素時,這可能是因為快取或者你需要等到動態內容渲染完成後在再嘗試。

您需要等待元素出現

點選文字輸入框

如果遇到這種情況 Neither element nor any descendant has keyboard focus, 這是解決方法

  • 如果你在模擬器上測試, 請確保沒有選中 Simulator -> Hardware -> Keyboard -> Connect Hardware Keyboard
  • 點選後稍微 稍等 一下
app.emailTextField.tap()
複製程式碼

清除所有文字

此舉是為了將游標移動到文字框末尾,然後依次刪除每一個字元,並鍵入新的文字

extension XCUIElement {
  func deleteAllText() {
    guard let string = value as? String else {
      return
    }

    let lowerRightCorner = coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.9))
    lowerRightCorner.tap()

    let deletes = string.characters.map({ _ in XCUIKeyboardKeyDelete }).joined(separator: "")
    typeText(deletes)
  }
}
複製程式碼

修改語言環境

對我來說,我想用挪威語進行測試,所以我們需要找到 Norwegian 選項並點選它。它被 UI Test 識別為靜態文字

var norwegianText: XCUIElement {
  return staticTexts["Norsk (bokmål)"]
}

wait(for: app.norwegianText, timeout: 1)
app.norwegianText.tap()
複製程式碼

郵箱賬戶輸入框

幸運的是,郵箱賬戶輸入框可以被 UI Test 檢測為 text field 元素,因此我們可以查詢它。 這裡使用謂詞

var emailTextField: XCUIElement {
  let predicate = NSPredicate(format: "placeholderValue == %@", "E-post eller mobil")
  return textFields.element(matching: predicate)
}
複製程式碼

密碼輸入框

UI Test 似乎無法識別出密碼輸入框,因此我們需要通過 coordinate 進行搜尋

var passwordCoordinate: XCUICoordinate {
  let vector = CGVector(dx: 1, dy: 1.5)
  return emailTextField.coordinate(withNormalizedOffset: vector)
}
複製程式碼

下面是這個方法的文件描述func coordinate(withNormalizedOffset normalizedOffset: CGVector) -> XCUICoordinate

建立並返回帶有標準化偏移量的新座標。 座標的螢幕點是通過將 normalizedOffset 乘以元素 frame 的大小與元素 frame 的原點相加來計算的。

然後輸入密碼

app.passwordCoordinate.tap()
app.typeText("My password")
複製程式碼

我們不應該使用 app.passwordCoordinate.referencedElement 因為它會指向郵箱賬戶輸入框 ❗️ ?

再次執行該測試

這裡我們從 Xcode -> Product -> Perform Actions -> Test Again 再次執行上一個測試

[譯] 在 iOS 中使用 UITests 測試 Facebook 登入功能

以下是完整的原始碼

import XCTest
class LoginTests: XCTestCase {
  var app: XCUIApplication!
  func testLogin() {
    continueAfterFailure = false
    app = XCUIApplication()
    app.launch()
    passLogin()
  }
}
extension LoginTests {
  func passLogin() {
    // Tap login
    app.showFacebookLoginFormButton.tap()
    wait(for: app.staticTexts["Facebook"], timeout: 5) // This requires a high timeout
     
    // There may be location permission popup when showing map
    handleLocationPermission()    
    if app.isAlreadyLoggedInSafari {
      app.okButton.tap()
      // Show map
      let map = app.maps.element(boundBy: 0)
      wait(for: map, timeout: 2)
      XCTAssertTrue(map.exists)
      // Need to interact with the app for interruption monitor to work
      app.tap()
    } else {
      // Choose norsk
     wait(for: app.norwegianText, timeout: 1)
      app.norwegianText.tap()
      app.emailTextField.tap()
      app.emailTextField.deleteAllText()
      app.emailTextField.typeText("mujyhwhbby_1496155833@tfbnw.net")
      app.passwordCoordinate.tap()
      app.typeText("Bob Alageaiecghfb Sharpeman")
      // login
      app.facebookLoginButton.tap()
      // press OK
      app.okButton.tap()
      // Show map
      let map = app.maps.element(boundBy: 0)
      wait(for: map, timeout: 2)
      XCTAssertTrue(map.exists)
      // Need to interact with the app for interruption monitor to work
      app.tap()
    }
  }
  fileprivate func handleLocationPermission() {
    addUIInterruptionMonitor(withDescription: "Location permission", handler: { alert in
      alert.buttons.element(boundBy: 1).tap()
      return true
    })
  }
}
fileprivate extension XCUIApplication {
  var showFacebookLoginFormButton: XCUIElement {
    return buttons["Continue with Facebook"]
  }
  var isAlreadyLoggedInSafari: Bool {
    return buttons["OK"].exists || staticTexts["Du har allerede godkjent Blue Sea."].exists
  }
  var okButton: XCUIElement {
    return buttons["OK"]
  }
  var norwegianText: XCUIElement {
    return staticTexts["Norsk (bokmål)"]
  }
  var emailTextField: XCUIElement {
    let predicate = NSPredicate(format: "placeholderValue == %@", "E-post eller mobil")
    return textFields.element(matching: predicate)
  }
  var passwordCoordinate: XCUICoordinate {
    let vector = CGVector(dx: 1, dy: 1.5)
    return emailTextField.coordinate(withNormalizedOffset: vector)
  }
  var facebookLoginButton: XCUIElement {
    return buttons["Logg inn"]
  }
}
extension XCTestCase {
  func wait(for duration: TimeInterval) {
    let waitExpectation = expectation(description: "Waiting")
    let when = DispatchTime.now() + duration
    DispatchQueue.main.asyncAfter(deadline: when) {
      waitExpectation.fulfill()
    }
    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }
  /// Wait for element to appear
  func wait(for element: XCUIElement, timeout duration: TimeInterval) {
    let predicate = NSPredicate(format: "exists == true")
    let _ = expectation(for: predicate, evaluatedWith: element, handler: nil)
    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }
}
extension XCUIApplication {
  // Because of "Use cached accessibility hierarchy"
  func clearCachedStaticTexts() {
    let _ = staticTexts.count
  }
  func clearCachedTextFields() {
    let _ = textFields.count
  }
  func clearCachedTextViews() {
    let _ = textViews.count
  }
}
extension XCUIElement {
  func deleteAllText() {
    guard let string = value as? String else {
      return
    }
    let lowerRightCorner = coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.9))
    lowerRightCorner.tap()
    let deletes = string.characters.map({ _ in XCUIKeyboardKeyDelete }).joined(separator: "")
    typeText(deletes)
  }
}
複製程式碼

另外一點

感謝這些我原創文章的有用反饋 github.com/onmyway133/…, 這裡有一些更多的點子

  • 要查詢密碼輸入框,實際上我們可以使用 secureTextFields 來代替使用 coordinate
  • wait 函式應該作為 XCUIElement 的擴充套件,以便於其他元素可以使用它。或者你可以使用舊的 expectation 樣式,這不涉及硬編碼的間隔值。

進一步擴充

這些指南涵蓋了 UITests 許多方面的內容,值得一看

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章