使用 AppIntentsTesting 验证你的 App Intents 采用情况
Validate your App Intents adoption with AppIntentsTesting
2026年6月10日
一句话判断
这场 Session 最值得关注的一件事是:Apple 终于把 App Intents(应用意图)的测试从“玄学 UI 自动化”变成了“确定性的跨进程集成测试”,你不再需要对着 Siri 喊破喉咙或者写脆弱的 UI 脚本来验证 Shortcuts(快捷指令)和 Spotlight(聚焦搜索)了。
这场 Session 讲了什么
以前我们要测 App Intents,要么写极度脆弱的 UI 测试去点 Shortcuts 界面,要么在代码里直接调 perform(),但这完全绕过了系统的参数解析和 Entity(实体)匹配。现在 Apple 推出了 AppIntentsTesting 框架。它寄生在 XCUITest 里,但通过 Bundle ID 跨进程调用真实的 App Intents 栈。
这意味着你可以像写单元测试一样,用代码直接构造 Intent、传入参数、执行并断言返回值。它没有 Mock(模拟),没有 Stub(桩),走的是和用户一模一样的系统底层代码路径。这对重度依赖 Siri、Shortcuts 自动化以及 Spotlight 搜索的 App 来说,绝对是救命的工具,直接把 App Intents 的工程化体验拉到了现代 iOS 开发的及格线以上。
值得深挖的点
跨进程测试与“无导入”设计的 Trade-off
AppIntentsTesting 最反直觉的设计是:测试 Target 不允许 import 你的 App 代码,只能传入 Bundle ID。Apple 这么做是为了强制测试走真实的 IPC(进程间通信)和系统级 Intent 路由。如果你直接 import 代码,测试就跑在同一个进程,根本无法验证系统把字符串 “Deep Space” 解析为 CalendarEntity 的真实过程,也测不出权限隔离和进程拉起的问题。
代价是失去了编译期的类型检查。在调用 makeIntent(name: "Occupy Saturn", color: "red") 时,参数名和类型没有代码补全,拼错参数名只会在运行时报错。但比起以前用 UI 自动化去点屏幕,这种运行时的“盲写”换来的是极高的真实环境置信度和 CI(持续集成)稳定性。这是一个非常务实的妥协,用一点点编写时的不便,换取了系统级集成测试的可行性。
补齐系统级集成的测试盲区:Spotlight 与 View Annotations
这个框架最让我惊喜的是它不仅能测 Intent 执行,还能测系统集成的“副作用”。以前 Spotlight 索引没生效,或者 Siri 屏幕感知(On-screen awareness)拿错了数据,开发者只能等用户反馈才发现。
现在你可以直接用 spotlightQuery() 断言 CoreSpotlight 的索引状态,甚至用 viewAnnotations() 验证 Siri 在屏幕上“看”到的 Entity ID 到底对不对。配合 #if DEBUG 和 isDiscoverable = false 打造的 Test-only Intents(仅用于测试的意图),你可以在测试开始前快速 Seed(播种)已知状态的数据。这套组合拳直接消灭了“UI 看着正常但系统底层没索引上”的幽灵 Bug。
代码片段
1. 像写单元测试一样执行 Intent
场景:验证创建一个新日历的 Intent 是否能正确返回新建的 Entity。
import AppIntentsTesting
func testCreateCalendar() async throws {
// 通过 Bundle ID 初始化,不导入 App 代码
let definitions = IntentDefinitions(bundleIdentifier: "com.example.apple-samplecode.CometCal")
// 通过字符串下标获取 Intent 定义
let createCalendar = definitions.intents["CreateCalendarIntent"]
// 构造并执行 Intent,参数名需与 App 中定义的一致
let result = try await createCalendar.makeIntent(
name: "Occupy Saturn",
color: "red"
).run()
// 利用 dynamic member lookup 直接断言返回实体的属性
XCTAssertEqual(try result.value.title, "Occupy Saturn")
}
坑:makeIntent 的参数没有编译器检查,如果 App 里的参数名从 name 改成了 title,这里不会报错,只会在运行时抛出找不到参数的异常。
2. 回归测试 Spotlight 索引
场景:确保新建的事件能正确被系统 Spotlight 搜到,防止索引代码被意外注释。
func testNewEventIndexedInSpotlight() async throws {
let definitions = IntentDefinitions(bundleIdentifier: "com.example.apple-samplecode.CometCal")
let eventEntity = definitions.entities["EventEntity"]
// 验证创建前 Spotlight 搜不到该事件
let before = try await eventEntity.spotlightQuery("Supernova Viewing Party")
XCTAssertTrue(before.isEmpty, "事件此时不应存在于 Spotlight 中")
// 执行创建事件的 Intent (代码省略)
// try await definitions.intents["CreateEventIntent"].makeIntent(...).run()
// 验证创建后 Spotlight 能精确搜到 1 条结果
let after = try await eventEntity.spotlightQuery("Supernova Viewing Party")
XCTAssertEqual(after.count, 1)
XCTAssertEqual(try after[0].title, "Supernova Viewing Party")
}
坑:Spotlight 索引是异步的,如果 App 里的索引逻辑有延迟,测试可能会因为时序问题偶发失败,必要时需在 App 侧确保索引完成回调。
3. 验证 Siri 屏幕感知 (View Annotations)
场景:验证当前屏幕显示的 Entity 是否能被 Siri 正确识别,防止传错 ID。
func testEventViewAnnotation() async throws {
let definitions = IntentDefinitions(bundleIdentifier: "com.example.apple-samplecode.CometCal")
// 先通过 Intent 打开特定事件页面
try await definitions.intents["OpenEventIntent"]
.makeIntent(target: "Morning Launch Briefing").run()
// 用 XCUITest 确认 UI 已经渲染出该事件
let app = XCUIApplication()
XCTAssertTrue(app.staticTexts["Morning Launch Briefing"].waitForExistence(timeout: 5))
// 获取当前屏幕的视图注释
let annotations = try await definitions.entities["EventEntity"].viewAnnotations()
// 断言屏幕上只有一个事件注释,且 ID/Title 正确
XCTAssertEqual(annotations.count, 1, "屏幕上应只有一个事件注释")
XCTAssertEqual(try annotations[0].entity.title, "Morning Launch Briefing")
}
坑:viewAnnotations() 强依赖当前 UI 状态,如果前面的 Intent 没有成功跳转页面,或者页面还在加载,这里拿到的数组就是空的。
最佳实践
已有项目如果已经接入了 App Intents,建议立刻把 CI 里那些靠点击 Shortcuts 界面来验证的 UI 测试删掉,换成 AppIntentsTesting。这能让你的测试运行时间缩短 80% 以上,且彻底告别“按钮位置变了导致测试挂了”的破事。
新项目在采用时,强烈建议为测试专门编写 Seed Intent(数据播种意图)。把这些 Intent 用 #if DEBUG 包裹,并设置 isDiscoverable = false。这样你可以在 setUp() 方法里一键清空数据库并注入固定测试数据,让每个测试用例都保持绝对的幂等性。
实战中最容易踩的坑是 Entity 的解析失败。当你在测试里传入一个字符串(比如日历名称 “Deep Space”),系统会在后台调用你的 EntityQuery 去匹配真实的 Entity。如果你的 Query 没写好,Intent 执行会直接报错。这也是为什么 Session 里强调要用 TDD(测试驱动开发)的方式,先写 entities(matching:) 的测试,再去实现 Query 逻辑。
还有什么值得关注
- 自定义类型传参:如果你需要给 Intent 传自定义结构体,可以通过实现
IntentValueConvertibleWrapper来完成类型转换,不用全塞进字典里。 - Dynamic Member Lookup 语法糖:返回的
result.value支持动态成员查找,你可以直接.title、.startDate访问属性,省去了大量恶心的类型强转和解包代码。 - 测试工作流定位:Apple 明确了边界,底层的业务逻辑依然用 XCTest 做单元测试,
AppIntentsTesting专门用来做跨进程和系统集成的“最后一公里”验收。