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

圖片來源: 谷歌前端

今天我正試圖在個人應用程序上運行一些 UITest,它集成了 Facebook 登陸功能。如下是個人一些筆記。android

挑戰

  • 對咱們來講,使用 Facebook 的挑戰主要在於, 它使用了 Safari controller,而咱們主要處理 web view。從 iOS 9+ 開始,Facebook 決定使用 safari 取代 native facebook app 以此來避免應用間的切換。你能夠在這裏閱讀詳細信息 在iOS 9上爲人們構建最佳的 Facebook 登陸體驗
  • 它並無咱們想要的 accessibilityIdentifier 或者 accessibilityLabel
  • webview 內容未來可能會發生變化 😸

建立一個 Facebook 測試用戶

幸運的是,您沒必要建立本身的 Facebook 用戶用於測試。Facebook 支持建立測試用戶,能夠管理權限和好友,很是方便ios

當咱們建立測試用戶時,您還能夠選擇不一樣語言。這將是 Safari Web 視圖中顯示的語言。我如今選擇的是 Norwegian 🇳🇴git

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

這裏咱們使用默認的 FBSDKLoginButtongithub

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

而後點擊它web

app.showFacebookLoginFormButton.tap()
複製代碼

檢查登陸狀態

當在 Safari 訪問 Facebook 表單時,用戶也許已經登陸過,也許沒有。因此咱們須要處理這兩種狀況。因此咱們須要處理這兩個場景。當用戶已經登陸時,Facebook 會返回你已經登陸OK 按鈕。後端

這裏的建議是添加斷點,而後使用 lldb 命令 po app.staticTextspo app.buttons,查看當前斷點下的 UI 元素。xcode

您能夠檢查靜態文本,或只是點擊 OK 按鈕緩存

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

等待並刷新

由於 Facebook 表單是一個 webview ,因此它的內容是有點動態的。而且 UITest 彷佛會緩存內容以便快速查詢,所以在檢查 staticTexts 以前,咱們須要 waitrefresh the cachebash

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 再次運行上一個測試

如下是完整的源代碼

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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索