WWDC Quick Look 💓 By SwiftGGTeam
AppIntentsTestingでApp Intents導入を検証する

AppIntentsTestingでApp Intents導入を検証する

元の動画を見る

ハイライト

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ランタイムがCalendarEntityEntityStringQueryを自動で呼び、最初の結果にマッチさせます。
  • 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

コメント

GitHub Issues · utterances