WWDC Quick Look đź’“ By SwiftGGTeam
Validate your App Intents adoption with AppIntentsTesting

Validate your App Intents adoption with AppIntentsTesting

Watch original video

Highlight

Apple introduces the AppIntentsTesting framework, which lets developers execute real App Intents across processes from XCUITest by using a bundle ID. You can validate intent execution, entity queries, Spotlight indexing, and view annotations without relying on mocks.

Core Content

Why App Intents used to be hard to test

Your app may integrate with Siri, Shortcuts, and Spotlight so users can operate it by voice or shortcuts. Testing those capabilities has always been difficult.

Option one: write UI tests that tap through the Shortcuts interface. As soon as a button moves, the test breaks. A single run takes tens of seconds and is extremely unstable in CI.

Option two: call perform() directly in code. This bypasses system parameter parsing, entity matching, and permission checks, so it does not test the real path.

The result: many teams simply do not test these flows, and only discover later from user feedback that Siri does not understand them, Spotlight cannot find them, or Shortcuts passes the wrong parameters.

What AppIntentsTesting does

Apple built the testing framework directly into the system. Test code lives in a standard XCUITest bundle and uses IntentDefinitions(bundleIdentifier:) to retrieve all intents, entities, and queries defined by the app. At runtime, the intent executes for real inside the app process, and the result is returned to the test process through IPC.

The test target does not import app code; it only passes the bundle ID. This forces tests through the real system route, including parameter parsing, entity lookup, and permission checks.

The cost is that there is no compile-time type checking. Parameter names and types in makeIntent(name:color:) do not get code completion, and typos fail only at runtime. Apple considers this tradeoff worthwhile: a little inconvenience while writing tests buys more reliable system-level integration tests.

Tests can cover system-level integrations too

AppIntentsTesting does more than test intent execution. It can also test whether Spotlight indexing works and whether view annotations pass the correct entity ID. These used to be black boxes that could not be tested at all.

Details

Execute your first intent test

(06:48)

Using the CreateCalendarIntent from the CometCal sample app, create a calendar and assert the return value:

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")
}

Key points:

  • IntentDefinitions is initialized with a bundle ID and does not depend on importing app code.
  • intents["CreateCalendarIntent"] retrieves an intent definition through a string subscript.
  • Parameter names in makeIntent must match the definitions in the app, but the compiler does not check them.
  • color: "red" is the raw string for an AppEnum, and the framework automatically converts the type.
  • result.value accesses returned entity properties through dynamic member lookup.
  • The test runs in a separate process, while the app executes the intent in another process, so state is not shared.

Test entity string queries

(12:25)

When Shortcuts and Siri search for entities, they call EntityStringQuery. The following example implements it in a test-driven way:

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")
}

Write the test first and watch it fail. Then implement the query:

struct EventEntityQuery: EntityStringQuery {
    func entities(for identifiers: [EventEntity.ID]) async throws -> [EventEntity] {
        // Look up by ID
    }

    func suggestedEntities() async throws -> [EventEntity] {
        // Return a suggested list
    }

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

Key points:

  • entities(matching:) executes the string query for real on device.
  • The returned EventEntity array can be asserted through dynamic member lookup.
  • Write the test first, then implement the query, so the TDD flow is complete.
  • After the fix, searching for the same event in Shortcuts returns the correct result.

Chain multiple intents together

(15:42)

A core Shortcuts use case is passing the output of one intent into the next. Tests can simulate this flow:

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")
}

Key points:

  • calendar: "Deep Space" passes a string, and the App Intents runtime automatically calls the EntityStringQuery for CalendarEntity to match the first result.
  • event: createResult.value passes the entity returned by the previous intent directly into the next intent.
  • Chaining simulates the way users connect actions by dragging them together in Shortcuts.
  • The whole flow completes in one test: create, assert, update, and assert again.

Test-only intents

(17:45)

Tests need isolated, repeatable data environments. Use isDiscoverable = false and #if DEBUG to create intents that exist only for testing:

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

    func perform() async throws -> some IntentResult {
        // Create events from a known list
        return .result()
    }
}
#endif

Key points:

  • isDiscoverable = false prevents the system from exposing this intent anywhere.
  • #if DEBUG ensures the release build does not contain test code.
  • It can be used to inject test data, jump to any screen, or reset app state.
  • Even if the app UI changes, the test remains stable.

Test Spotlight indexing

(20:27)

After the app creates an event, that event should be indexed in Spotlight automatically. Verify this with spotlightQuery():

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

    // Create the "Supernova Viewing Party" event

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

Key points:

  • spotlightQuery(_:) communicates directly with Spotlight and returns the list of matching entities.
  • First assert that the event cannot be found before creation, then assert that it can be found afterward to prevent regressions.
  • This test can catch silent bugs such as accidentally commenting out indexing code.

Test view annotations

(22:33)

View annotations tell Siri which entity is currently displayed on screen. If they are wrong, Siri may answer “What is this?” with the wrong information:

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")
}

Key points:

  • First open the page with an intent, then use XCUI to confirm the UI rendered correctly.
  • viewAnnotations() retrieves the list of current screen entities reported by the system.
  • annotations[0].entity accesses entity properties through dynamic member lookup.
  • This test once helped the presenter discover a bug where calendar.id had been passed instead of event.id.

Key Takeaways

1. Put Siri automation tests into CI

If your app has Siri shortcuts, you can now write automated tests for core paths. Pick the most common voice command, execute it with makeIntent(...).run(), and assert the result. Run it on every commit so Siri regressions are caught immediately.

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

2. Add regression tests for Spotlight indexing

If you use CoreSpotlight to index app content, write a test where spotlightQuery returns empty before content is created and returns one result afterward. This prevents indexing logic from being broken accidentally.

Entry API: entityDefinition.spotlightQuery("keyword")

3. Manage test data with test-only intents

Write a SeedDataIntent wrapped in #if DEBUG. Call it in setUp() to inject fixed data. Each test case can start from zero instead of depending on dirty data left by a previous test.

Entry API: static let isDiscoverable = false

4. Validate Siri screen awareness

If your app supports contextual commands such as “Siri, what is this?”, write a view annotation test. Open a specific page and assert that viewAnnotations() returns the correct entity ID.

Entry API: entityDefinition.viewAnnotations()

5. Use TDD to implement entity queries

The next time you implement EntityStringQuery, write the test for entities(matching:) first, see it fail, and then implement the query. It is much faster than manually verifying search inside Shortcuts.

Entry API: entityDefinition.entities(matching: "search term")

Comments

GitHub Issues · utterances