ハイライト
AppleはAppIntentsTestingフレームワークを導入しました。開発者はXCUITestからBundle IDを使って、プロセスをまたいで実際のApp Intentsを実行できます。Intentの実行、Entity Query、Spotlightインデックス、View Annotationを、Mockなしで検証できます。
主要内容
以前のApp Intentsテストはなぜ面倒だったのか
アプリがSiri、Shortcuts、Spotlightに対応していると、ユーザーは音声やショートカットでアプリを操作できます。しかし、これらの機能をテストするのはずっと難題でした。
選択肢の1つは、Shortcutsの画面をタップしていくUIテストを書くことです。ボタンの位置が変わるだけでテストは壊れます。1回の実行に数十秒かかり、CIでは非常に不安定です。
もう1つは、コード内でperform()を直接呼ぶことです。しかしこれは、システムのパラメータ解析、Entityのマッチング、権限チェックを迂回してしまい、実際の経路をテストできません。
その結果、多くのチームはこれらのフローをテストせず、Siriが理解しない、Spotlightで見つからない、Shortcutsが誤ったパラメータを渡す、といった問題をユーザーからのフィードバックで初めて知ることになります。
AppIntentsTestingがすること
Appleはテストフレームワークをシステムに直接組み込みました。テストコードは標準のXCUITest bundleに書き、IntentDefinitions(bundleIdentifier:)でアプリ内に定義されたすべてのIntent、Entity、Queryを取得します。テスト実行時、Intentはアプリプロセス内で実際に実行され、結果はIPCでテストプロセスへ返ります。
テストTargetはアプリのコードをimportせず、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: "土星を占拠",
color: "red"
).run()
XCTAssertEqual(try result.value.title, "土星を占拠")
}
重要な点:
IntentDefinitionsはBundle IDで初期化し、アプリコードのimportに依存しません。intents["CreateCalendarIntent"]は文字列subscriptでIntent定義を取得します。makeIntentのパラメータ名はアプリ内の定義と一致している必要がありますが、コンパイラはチェックしません。color: "red"はAppEnumのraw stringで、フレームワークが自動で型変換します。result.valueはdynamic member lookupで返されたEntityのプロパティにアクセスします。- テストは独立したプロセスで実行され、アプリは別プロセスでIntentを実行するため、状態は共有されません。
Entityの文字列Queryをテストする
(12:25)
ShortcutsやSiriがEntityを検索するときは、EntityStringQueryが呼ばれます。次の例では、テスト駆動でそれを実装します。
func testEventStringQuery() async throws {
let results = try await eventEntityDefinition
.entities(matching: "宇宙線")
XCTAssertEqual(results.count, 1)
XCTAssertEqual(try results[0].title, "宇宙線較正")
}
先にテストを書き、実行して失敗を確認します。次にQueryを実装します。
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:)はデバイス上で実際に文字列Queryを実行します。- 返された
EventEntity配列はdynamic member lookupでプロパティを検証できます。 - 先にテストを書き、その後Queryを実装することで、TDDの流れが完成します。
- 修正後は、Shortcutsで同じイベントを検索すると正しい結果が表示されます。
複数のIntentをチェーンする
(15:42)
Shortcutsの中心的な使い方は、あるIntentの出力を次のIntentへ渡すことです。テストではこの流れをシミュレートできます。
func testCreateAndUpdateEvent() async throws {
let createResult = try await createEventDefinition.makeIntent(
title: "小惑星ドッジボールの練習",
startDate: Date(),
isAllDay: false,
calendar: "深宇宙"
).run()
XCTAssertEqual(try createResult.value.title, "小惑星ドッジボールの練習")
let updateResult = try await updateEventDefinition.makeIntent(
title: "小惑星ドッジボールのルール概要",
event: createResult.value
).run()
XCTAssertEqual(try updateResult.value.title, "小惑星ドッジボールのルール概要")
}
重要な点:
calendar: "深宇宙"は文字列を渡しており、App IntentsランタイムがCalendarEntityのEntityStringQueryを自動で呼び、最初の結果にマッチさせます。event: createResult.valueは、前のIntentが返したEntityをそのまま次のIntentへ渡します。- チェーン呼び出しは、ユーザーがShortcuts内でアクションをドラッグして接続する動きを再現します。
- 作成、検証、更新、再検証まで、1つのテストで完結します。
テスト専用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ビルドにはテストコードが含まれません。- テストデータの注入、任意の画面への遷移、アプリ状態のリセットに使えます。
- アプリのUIが変わっても、テストは安定します。
Spotlightインデックスをテストする
(20:27)
アプリがイベントを作成したら、そのイベントは自動的にSpotlightへインデックスされるべきです。spotlightQuery()で検証します。
func testNewEventIndexedInSpotlight() async throws {
let before = try await eventEntityDefinition.spotlightQuery("超新星観望会")
XCTAssertTrue(before.isEmpty, "このイベントはまだSpotlightに存在しないはずです")
// "超新星観望会"イベントを作成する
let after = try await eventEntityDefinition.spotlightQuery("超新星観望会")
XCTAssertEqual(after.count, 1)
XCTAssertEqual(try after[0].title, "超新星観望会")
}
重要な点:
spotlightQuery(_:)はSpotlightと直接通信し、一致するEntityの一覧を返します。- 作成前に見つからないことを先に検証し、作成後に見つかることを検証することで回帰を防ぎます。
- このテストは、「インデックス処理のコードを誤ってコメントアウトした」といった静かなBugを捕まえられます。
View Annotationをテストする
(22:33)
View Annotationは、現在画面に表示されているEntityをSiriへ伝えます。これが間違っていると、Siriが「これは何?」と聞かれたときに誤った情報を返します。
func testEventViewAnnotation() async throws {
try await openEventDefinition.makeIntent(target: "朝の打ち上げブリーフィング").run()
let app = XCUIApplication()
let title = app.staticTexts["朝の打ち上げブリーフィング"]
XCTAssertTrue(title.waitForExistence(timeout: 5))
let annotations = try await eventEntityDefinition.viewAnnotations()
XCTAssertEqual(annotations.count, 1, "View Annotationは1件だけであるべきです")
XCTAssertEqual(try annotations[0].entity.title, "朝の打ち上げブリーフィング")
}
重要な点:
- まずIntentでページを開き、次にXCUIでUIが正しく描画されたことを確認します。
viewAnnotations()は、システムが報告する現在画面のEntity一覧を取得します。annotations[0].entityはdynamic member lookupでEntityプロパティへアクセスします。- このテストは、発表者が
event.idではなくcalendar.idを誤って渡していたBugを見つける助けになりました。
重要ポイント
1. Siriの自動化テストをCIに入れる
アプリにSiriショートカットがあるなら、主要経路を自動テストでカバーできるようになりました。もっともよく使われる音声指示を1つ選び、makeIntent(...).run()で実行して結果を検証します。コミットごとに走らせれば、Siri関連の回帰をすぐ見つけられます。
入口API: IntentDefinitions(bundleIdentifier:) + intents["IntentName"].makeIntent(...).run()
2. Spotlightインデックスに回帰テストを追加する
CoreSpotlightでアプリ内容をインデックスしているなら、コンテンツ作成前はspotlightQueryが空を返し、作成後は1件返すテストを書きます。インデックス処理が意図せず壊れるのを防げます。
入口API: entityDefinition.spotlightQuery("keyword")
3. テスト専用Intentでテストデータを管理する
SeedDataIntentを作り、#if DEBUGで包みます。setUp()で呼び出して固定データを注入すれば、各テストケースは前のテストが残した汚れたデータに依存せず、ゼロから始められます。
入口API: static let isDiscoverable = false
4. Siriの画面認識の正しさを検証する
アプリが「Siri、これは何?」のような文脈指示をサポートしているなら、View Annotationテストを書きます。特定のページを開き、viewAnnotations()が正しいEntity IDを返すことを検証します。
入口API: entityDefinition.viewAnnotations()
5. Entity QueryをTDDで実装する
次にEntityStringQueryを実装するときは、先にentities(matching:)のテストを書き、失敗を確認してからQueryを実装します。Shortcuts内で手動検索して検証するより、はるかに速く進められます。
入口API: entityDefinition.entities(matching: "search term")
関連 Session
- App Intentsでagentic appを構築する - App Intentsの新機能を理解し、テストでさらに多くの範囲をカバーします
- App Intentsの新機能 - App Intentsの新しいAPIを把握することは、テストを書く前提になります
- App Intentsをさらに活用する - App Intentsの高度な使い方を掘り下げ、より豊かなテストシナリオを扱います
- 優れたWidgetをデザインする - WidgetもApp Intentsに依存してデータを提供するため、このテストフレームワークを同じように活用できます
- Siriの新機能 - Siri体験の品質は、App Intentsの正しさに左右されます
コメント
GitHub Issues · utterances