[译] 在 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索