WWDC Quick Look 💓 By SwiftGGTeam
Handle interruptions and alerts in UI tests

Handle interruptions and alerts in UI tests

观看原视频

Highlight

XCTest 在 Xcode 12 中能隐式处理 banner notification,并提供 addUIInterruptionMonitorXCUIApplication.resetAuthorizationStatus(for:),让 UI 测试能分别处理非确定性中断、预期 alert 和隐私权限初始状态。

核心内容

UI 测试有一种很隐蔽的失败路径:测试准备点击一个元素时,另一个 UI 临时挡住了它。recipe 列表里有一个通知横幅,如果它没有盖住当前要点的 cell,测试可以继续。进入详情页后,同一个横幅盖住返回按钮,测试就需要先处理这个面板,再继续点击返回。

Apple 给这个场景一个清晰边界:UI interruption 是非预期、非确定性出现,并且挡住测试目标元素的 UI。banner、alert、dialog、window 都可能成为 interruption。直接由测试动作触发的 alert 不属于这个分类,比如删除一条 recipe 后出现的确认框,它应该作为测试流程的一部分被查询、等待和断言。

XCTest 提供 addUIInterruptionMonitor 注册 closure。多个 handler 会形成栈,后注册的先执行。handler 处理成功就返回 true,测试继续;处理不了就返回 false,XCTest 会继续找下一个 handler。最底层还有 XCTest 自带的隐式 handler:iOS 会处理带 Cancel 或默认按钮的中断,Xcode 12 起还能处理 banner notification;macOS 会处理常见权限对话框和 Bluetooth Setup Assistant。

隐私权限带来另一类问题。系统会记住用户对照片、麦克风、定位等 protected resource 的选择。第一次运行测试后,设备已经不再是干净状态。Xcode 11.4、iOS 和 tvOS 13.4、macOS 10.15.4 开始,XCUIApplication 可以重置 protected resource 的授权状态,让首次授权流程重新变成可重复测试的路径。session 还提醒开发者:这些 alert 来自系统,查询层级要按系统 UI 写;重置授权状态也可能终止 app 进程。

详细内容

先给非确定性中断注册 handler

05:04)测试先注册一个 interruption monitor。这个 handler 会在 XCTest 发现 alert 挡住目标元素时被调用。最小骨架只拿到 interrupting element,并用返回值告诉 XCTest 是否已经处理。

addUIInterruptionMonitor(withDescription: "Handle recipe update failures") { element -> Bool in
    // TODO: Use this handler to retry the update action
    return false
}

关键点:

  • withDescription 给 handler 命名,失败诊断时能知道它处理哪类中断。
  • closure 的 element 是挡住当前测试动作的 UI 元素。
  • 返回 false 表示当前 handler 没有处理,XCTest 会继续调用栈里的下一个 handler。
  • handler 会在测试结束时自动移除,也可以在测试中手动移除。
  • XCTest 自带的隐式 handler 位于栈底;自定义 handler 返回 false 后,才会轮到它尝试 Cancel、默认按钮或 Xcode 12 的 banner notification。

05:16)recipe 详情测试本身是普通 UI 测试:启动 app,点进 Pancakes,断言标题、图片、食材、步骤,再点返回。问题出在 server 更新 recipe 失败时,app 会弹出 alert,挡住测试下一步要访问的元素。

func testRecipeDetailsNavigation() throws {

    let app = XCUIApplication()
    app.launch()

    let pancakeRecipe = app.cells.staticTexts["Fluffy Pancakes"].firstMatch
    pancakeRecipe.tap()

    // In the detail view
    let detailTitle = app.navigationBars.staticTexts["Fluffy Pancakes"].firstMatch
    XCTAssert(detailTitle.waitForExistence(timeout: 30))

    let expectedImage = app.images["Fluffy Pancakes Image"].firstMatch
    XCTAssert(expectedImage.exists)

    let ingredientsTitle = app.staticTexts["Ingredients Title"].firstMatch
    XCTAssert(ingredientsTitle.exists)

    let ingredientsContent = app.textViews["Ingredients Content"].firstMatch
    XCTAssert(ingredientsContent.exists)
    XCTAssert((ingredientsContent.value as! String).count > 0)

    let instructionsTitle = app.staticTexts["Instructions Title"].firstMatch
    XCTAssert(instructionsTitle.exists)

    let instructionsTextView = app.textViews["Instructions Content"].firstMatch
    XCTAssert(instructionsTextView.exists)
    XCTAssert((instructionsTextView.value as! String).count > 0)

    // Make sure we received the latest instructions:
    let expectedInstructions = """
                               1. Mix the flour, sugar, baking powder and salt.

                               2. Pour the milk, melted butter and egg into a well in the center and mix.

                               3. Heat a frying pan (don't forget to add a little bit of oil, grandson!), and fry until they're golden.

                               4. Optionally add maple syrup and/or fruits (they're healthy!).
                               """
    XCTAssertEqual((instructionsTextView.value as! String), expectedInstructions)

    // Go back to the list of recipes
    let backButton = app.navigationBars.buttons["Grandma's Recipes"].firstMatch
    backButton.tap()

    XCTAssert(pancakeRecipe.waitForExistence(timeout: 30))
}

关键点:

  • waitForExistence(timeout:) 用在导航结果上,避免页面尚未出现就读取元素。
  • firstMatch 明确使用第一个匹配元素,让 query 不在多个候选之间摇摆。
  • 测试检查完整 instruction 文本,确保拿到的是最新 recipe 数据。
  • 后续点击返回按钮时,如果 alert 或 banner 覆盖按钮,interruption handler 才有机会介入。

让 handler 处理真正想要的按钮

06:20)XCTest 的隐式 handler 可以点 Cancel 或默认按钮。这里的业务目标是拿到最新 recipe,所以测试更应该点 Retry。handler 需要检查 element 是否为 alert,再查找 Retry 按钮。

addUIInterruptionMonitor(withDescription: "Handle recipe update failures") { element -> Bool in
    let retryButton = element.buttons["Retry"].firstMatch
    if element.elementType == .alert && retryButton.exists {
        retryButton.tap()
        return true
    } else {
        return false
    }
}

关键点:

  • element.elementType == .alert 把 handler 限定在 alert 场景。
  • element.buttons["Retry"].firstMatch 从 interrupting element 内部查找按钮,范围比全局 app query 更窄。
  • retryButton.tap() 执行业务上正确的恢复动作。
  • 返回 true 后,XCTest 会继续原来的测试动作。

预期 alert 要进入测试断言

07:37)删除 recipe 后出现的确认框是预期 alert。它由测试动作直接触发,出现时间可预测。Apple 建议用普通 query 和 wait API 处理它,让 alert 文案和确认动作参与断言。

func testDeleteRecipe() throws {
    let breadCell = cell(recipeName: "Banana Bread")
    deleteCell(breadCell)

    let alert = app.alerts["Delete Recipe"].firstMatch
    let alertExists = alert.waitForExistence(timeout: 30)
    XCTAssert(alertExists, "Expected alert to show up")

    let description = """
                      Are you sure you want to \
                      delete this recipe?
                      """

    let alertDescription = alert.staticTexts[description]
    XCTAssert(alertDescription.exists)

    alert.buttons["Delete"].tap()
    XCTAssertFalse(breadCell.exists)
}

关键点:

  • deleteCell(breadCell) 是触发 alert 的测试动作,alert 出现路径可控。
  • app.alerts["Delete Recipe"].firstMatch 直接查询目标 alert。
  • waitForExistence(timeout:) 把等待和失败信息留在测试里。
  • 先断言说明文案,再点 Delete,最后确认原 cell 消失。

重置 protected resource 的授权状态

10:29)系统会保存照片、麦克风、摄像头、定位等 protected resource 的授权选择。要测试首次请求权限的流程,需要先把 app 对某个资源的授权状态重置,再启动 app。

func testAddingPhotosFirstTime() throws {
    let app = XCUIApplication()
    app.resetAuthorizationStatus(for: .photos)

    app.launch()

    // Test code…
}

关键点:

  • resetAuthorizationStatus(for: .photos) 让 app 像从未请求过照片权限一样运行。
  • reset photos 授权状态可能终止 app 进程,所以示例在 reset 后调用 launch()
  • 权限 alert 来自系统,query 需要按系统 alert 的层级写。
  • 这个 API 适合把允许、拒绝、首次启动引导流程拆成可重复的 UI 测试。
  • session 明确提到的跨平台资源包括 Contacts、Calendar、Photos、microphone、camera 和 location;iOS 还覆盖 Keyboard、Network Access、Bluetooth,以及 Xcode 12 与 iOS 14 的 Health。

核心启发

  • 做什么:给网络更新失败的 UI 测试加 Retry handler。 为什么值得做:10220 的 demo 里,server 偶发失败会弹出 alert,默认 Cancel 会让测试继续使用旧数据。 怎么开始:在测试 setup 中注册 addUIInterruptionMonitor,只在 elementType == .alert 且存在 Retry 按钮时点击它并返回 true

  • 做什么:把确认删除、放弃编辑这类预期 alert 写成显式断言。 为什么值得做:预期 alert 是用户流程的一部分,测试应该检查标题、说明文本和确认按钮。 怎么开始:用 app.alerts["Alert Title"].firstMatch 查询,配合 waitForExistence(timeout:) 等待,再断言 staticTexts 和最终 UI 状态。

  • 做什么:为照片、麦克风、定位权限写首次授权测试矩阵。 为什么值得做:系统会保存授权决定,不重置状态就很难重复覆盖首次请求路径。 怎么开始:在每个权限测试开头调用 app.resetAuthorizationStatus(for:),随后重新 launch(),系统 alert 用系统 UI 层级查询,再测试允许和拒绝分支。

  • 做什么:把 interruption handler 分成通用层和业务层。 为什么值得做:XCTest 会按后进先出的顺序调用多个 handler,业务测试可以先处理特定 alert,兜底 handler 再处理 cancel/default 类中断。 怎么开始:为每类非确定性 alert 写带 description 的 handler,只有处理成功才返回 true,无法识别的 element 统一返回 false

关联 Session

评论

GitHub Issues · utterances