WWDC Quick Look 💓 By SwiftGGTeam
Validate your App Intents adoption with AppIntentsTesting

Validate your App Intents adoption with AppIntentsTesting

观看原视频

Highlight

Apple 推出 AppIntentsTesting 框架,让开发者可以在 XCUITest 中通过 Bundle ID 跨进程执行真实的 App Intents,验证 Intent 执行、Entity 查询、Spotlight 索引和 View Annotation,全程不走 Mock。

核心内容

以前测 App Intents 有多麻烦

你的 App 接入了 Siri、Shortcuts、Spotlight,用户可以通过语音或快捷指令操作你的 App。但测试这些能力一直是个难题。

选项一:写 UI 测试去点 Shortcuts 界面。按钮位置一变,测试就挂。运行一次要几十秒,CI 里极不稳定。

选项二:在代码里直接调用 perform()。这绕过了系统的参数解析、Entity 匹配和权限检查,测不到真实路径。

结果:很多团队干脆不测,等用户反馈才发现 Siri 听不懂、Spotlight 搜不到、快捷指令传错参数。

AppIntentsTesting 做了什么

Apple 把测试框架直接做进了系统。测试代码写在标准的 XCUITest bundle 里,通过 IntentDefinitions(bundleIdentifier:) 拿到 App 中定义的所有 Intent、Entity 和 Query。测试运行时,Intent 在 App 进程里真实执行,结果通过 IPC 返回到测试进程。

测试 Target 不导入 App 代码,只传 Bundle ID。这强制测试走真实的系统路由,参数解析、Entity 查找、权限检查一个不落。

代价是编译期没有类型检查。makeIntent(name:color:) 的参数名和类型没有代码补全,拼错只在运行时报错。Apple 认为这是值得的:用一点编写时的不便,换系统级集成测试的可靠性。

测试还能覆盖系统级集成

AppIntentsTesting 不只测 Intent 执行。它还能测 Spotlight 索引是否生效、View Annotation 是否传对了 Entity ID。这些是以前完全测不到的黑箱。

详细内容

执行第一个 Intent 测试

06:48

以 CometCal 示例 App 的 CreateCalendarIntent 为例,创建一个日历并断言返回值:

import AppIntentsTesting

func testCreateCalendar() async throws {
    let definitions = IntentDefinitions(bundleIdentifier: "com.example.apple-samplecode.CometCal")
    let createCalendar = definitions.intents["CreateCalendarIntent"]
    let result = try await createCalendar.makeIntent(
        name: "Occupy Saturn",
        color: "red"
    ).run()
    XCTAssertEqual(try result.value.title, "Occupy Saturn")
}

关键点:

  • IntentDefinitions 通过 Bundle ID 初始化,不依赖 App 代码导入
  • intents["CreateCalendarIntent"] 通过字符串下标获取 Intent 定义
  • makeIntent 的参数名需与 App 中定义的一致,但没有编译器检查
  • color: "red" 是 AppEnum 的 raw string,框架自动做类型转换
  • result.value 通过 dynamic member lookup 访问返回的 Entity 属性
  • 测试在独立进程运行,App 在另一进程执行 Intent,状态不共享

测试 Entity 字符串查询

12:25

Shortcuts 和 Siri 搜索 Entity 时,会调用 EntityStringQuery。下面用测试驱动的方式实现它:

func testEventStringQuery() async throws {
    let results = try await eventEntityDefinition
        .entities(matching: "Cosmic Ray")

    XCTAssertEqual(results.count, 1)
    XCTAssertEqual(try results[0].title, "Cosmic Ray Calibration")
}

测试先写,运行后失败。然后实现查询:

struct EventEntityQuery: EntityStringQuery {
    func entities(for identifiers: [EventEntity.ID]) async throws -> [EventEntity] {
        // 通过 ID 查找
    }

    func suggestedEntities() async throws -> [EventEntity] {
        // 返回建议列表
    }

    func entities(matching string: String) async throws -> [EventEntity] {
        try calendarManager.fetchEvents()
            .filter { $0.title.localizedCaseInsensitiveContains(string) }
            .map(\.entity)
    }
}

关键点:

  • entities(matching:) 在设备上真实执行字符串查询
  • 返回的 EventEntity 数组可用 dynamic member lookup 断言属性
  • 先写测试、再实现查询,确保 TDD 流程完整
  • 修复后 Shortcuts 中搜索同一事件,结果正确出现

链式调用多个 Intent

15:42

Shortcuts 的核心用法是把一个 Intent 的输出传给下一个 Intent。测试可以模拟这个流程:

func testCreateAndUpdateEvent() async throws {
    let createResult = try await createEventDefinition.makeIntent(
        title: "Asteroid Dodgeball Practice",
        startDate: Date(),
        isAllDay: false,
        calendar: "Deep Space"
    ).run()

    XCTAssertEqual(try createResult.value.title, "Asteroid Dodgeball Practice")

    let updateResult = try await updateEventDefinition.makeIntent(
        title: "Asteroid Dodgeball Rules Overview",
        event: createResult.value
    ).run()

    XCTAssertEqual(try updateResult.value.title, "Asteroid Dodgeball Rules Overview")
}

关键点:

  • calendar: "Deep Space" 传入字符串,AppIntents 运行时自动调用 CalendarEntityEntityStringQuery 匹配第一个结果
  • event: createResult.value 直接把上一个 Intent 返回的 Entity 传给下一个 Intent
  • 链式调用模拟了用户在 Shortcuts 中拖拽连接的行为
  • 整个流程在一个测试里完成,创建、断言、更新、再断言

测试专用 Intent

17:45

测试需要独立、可重复的数据环境。用 isDiscoverable = false#if DEBUG 创建只在测试中使用的 Intent:

#if DEBUG
struct SeedSampleEventsIntent: AppIntent {
    static let isDiscoverable = false

    func perform() async throws -> some IntentResult {
        // 创建已知列表的事件
        return .result()
    }
}
#endif

关键点:

  • isDiscoverable = false 阻止系统在任何地方暴露这个 Intent
  • #if DEBUG 确保 Release 包不包含测试代码
  • 可用于注入测试数据、跳转到任意页面、重置应用状态
  • 即使 App 的 UI 改版,测试依然稳定

测试 Spotlight 索引

20:27

App 创建事件后应该自动索引到 Spotlight。用 spotlightQuery() 验证:

func testNewEventIndexedInSpotlight() async throws {
    let before = try await eventEntityDefinition.spotlightQuery("Supernova Viewing Party")
    XCTAssertTrue(before.isEmpty, "Event should not exist in Spotlight yet")

    // 创建 "Supernova Viewing Party" 事件

    let after = try await eventEntityDefinition.spotlightQuery("Supernova Viewing Party")
    XCTAssertEqual(after.count, 1)
    XCTAssertEqual(try after[0].title, "Supernova Viewing Party")
}

关键点:

  • spotlightQuery(_:) 直接与 Spotlight 通信,返回匹配的 Entity 列表
  • 先断言创建前搜不到,再断言创建后能搜到,防止回归
  • 这个测试能捕获”索引代码被意外注释掉”这类静默 Bug

测试 View Annotation

22:33

View Annotation 告诉 Siri 当前屏幕显示的是哪个 Entity。测错了会导致 Siri 回答”这是什么”时给出错误信息:

func testEventViewAnnotation() async throws {
    try await openEventDefinition.makeIntent(target: "Morning Launch Briefing").run()

    let app = XCUIApplication()
    let title = app.staticTexts["Morning Launch Briefing"]
    XCTAssertTrue(title.waitForExistence(timeout: 5))

    let annotations = try await eventEntityDefinition.viewAnnotations()

    XCTAssertEqual(annotations.count, 1, "Expected exactly one view annotation")
    XCTAssertEqual(try annotations[0].entity.title, "Morning Launch Briefing")
}

关键点:

  • 先用 Intent 打开页面,再用 XCUI 确认 UI 渲染正确
  • viewAnnotations() 获取系统报告的当前屏幕 Entity 列表
  • annotations[0].entity 通过 dynamic member lookup 访问 Entity 属性
  • 这个测试曾帮演讲者发现把 calendar.id 误传为 event.id 的 Bug

核心启发

1. 把 Siri 自动化测试写进 CI

你的 App 如果有 Siri 快捷指令,现在可以写自动化测试覆盖核心路径。选一个最常用的语音指令,用 makeIntent(...).run() 执行并断言结果。每次提交自动跑,Siri 相关的回归能被立刻发现。

入口 API:IntentDefinitions(bundleIdentifier:) + intents["IntentName"].makeIntent(...).run()

2. 给 Spotlight 索引加回归测试

如果你用 CoreSpotlight 索引 App 内容,写一个测试:创建内容前 spotlightQuery 返回空,创建后返回一条结果。这能防止索引逻辑被意外破坏。

入口 API:entityDefinition.spotlightQuery("keyword")

3. 用测试专用 Intent 管理测试数据

给测试写一个 SeedDataIntent,用 #if DEBUG 包裹。在 setUp() 里调用它注入固定数据。每个测试用例从零开始,不再依赖上一个测试留下的脏数据。

入口 API:static let isDiscoverable = false

4. 验证 Siri 屏幕感知的正确性

如果你的 App 支持”Siri,这是什么”这类上下文指令,写一个 View Annotation 测试。打开特定页面,断言 viewAnnotations() 返回正确的 Entity ID。

入口 API:entityDefinition.viewAnnotations()

5. 用 TDD 实现 Entity 查询

下次实现 EntityStringQuery 时,先写 entities(matching:) 的测试,运行看到失败,再实现查询逻辑。这比在 Shortcuts 里手动搜索验证快得多。

入口 API:entityDefinition.entities(matching: "search term")

关联 Session

评论

GitHub Issues · utterances