Test Siri, Spotlight, And Shortcuts Integrations With AppIntentsTesting
In the App Schemas post, I focused on the modeling side of the WWDC26 Siri story: entities, schema domains, indexed content, action schemas, view annotations, and transfer.
The next question is more practical.
If Siri AI, Spotlight, and Shortcuts all sit on top of App Intents, how do you know what they can actually see? And how do you test that without spending the afternoon repeating the same Siri phrase into a beta OS?
That is the job of AppIntentsTesting.
The framework does not replace manual Siri testing. It gives you a lower layer to test first. You can execute the same intent code path that Shortcuts and Siri use, call entity queries directly, ask Spotlight what it has indexed, and inspect the entity annotations your SwiftUI views expose to the system.
That is a much better place to start.
Test below Siri first
Siri is the last mile. By the time a voice request succeeds or fails, several things have already happened:
- Siri interpreted the user’s language.
- The App Intents runtime chose an intent.
- Entity resolution found, or failed to find, the user’s data.
- Your app executed the intent.
- Spotlight may have been involved.
- View annotations may have supplied the “this” or “that” on screen.
Debugging all of that through a voice flow is miserable because the failure can be in any layer.
Start with the pieces the system depends on. If CreateTaskIntent cannot run through App Intents, Siri will not save it. If ContactEntityQuery.entities(matching:) cannot find “Mira”, Shortcuts will not offer the right option. If you are relying on indexed entities for semantic search and Spotlight has not indexed the entity, Spotlight and Siri AI have much less to work with. If the visible row has the wrong EntityIdentifier, Siri’s understanding of “this contact” is built on bad input.
The useful question is not “does Siri understand my app yet?” It is:
- Can the app execute the intent out of process?
- Can the system resolve my entities by identifier and by search text?
- Can Spotlight find the entity I just donated?
- Can the system see the entity currently visible in my SwiftUI UI?
- After those pass, does the real Siri or Shortcuts flow behave the way a person would expect?
AppIntentsTesting covers the first four.
Add a UI testing bundle
Apple’s WWDC26 session uses a normal UI testing bundle. That detail matters.
Your tests run in the test runner process. Your app runs separately. The test talks to your app through the App Intents infrastructure, not by importing app code and calling methods directly.
In Xcode, add a UI Testing Bundle for the app target you want to test. Pick the same development team as the app target. Apple calls out the signing-team requirement because the test runner and app need to be signed consistently for the framework to communicate with the app on device.
Then keep the test target honest:
import AppIntentsTesting
import XCTest
final class TaskIntentTests: XCTestCase {
private let app = XCUIApplication()
private var definitions: IntentDefinitions!
override func setUp() {
super.setUp()
continueAfterFailure = false
app.launch()
definitions = IntentDefinitions(
bundleIdentifier: "com.example.TaskBox"
)
}
}
There is no @testable import TaskBox here.
That is not a limitation to work around. It is the point. If the test target imports the app target and calls internal helpers, you can accidentally prove that your app module works while the App Intents surface is broken. IntentDefinitions starts from the app’s bundle identifier and discovers the intents, entities, enums, and queries the app exposes to the system.
The tradeoff is that tests are stringier than ordinary unit tests. Intent names, entity names, parameter names, and enum raw values need to match the definitions exposed by your app. You get a more realistic integration test in exchange.
One beta-cycle note: the snippets in this post follow the WWDC26 AppIntentsTesting session and early SDK shape. Check the documentation and the Xcode interface you are building against before treating every symbol as final.
Run an intent like Shortcuts would
Here is the smallest useful test. It asks the app’s App Intents surface for a definition, builds a populated intent with makeIntent, runs it, and asserts on the returned entity.
import AppIntentsTesting
import XCTest
final class TaskIntentTests: XCTestCase {
private let app = XCUIApplication()
private var definitions: IntentDefinitions!
override func setUp() {
super.setUp()
continueAfterFailure = false
app.launch()
definitions = IntentDefinitions(
bundleIdentifier: "com.example.TaskBox"
)
}
func testCreateTask() async throws {
let createTask = definitions.intents["CreateTaskIntent"]
let result = try await createTask.makeIntent(
title: "Review launch notes",
project: "June Release",
priority: "high"
).run()
XCTAssertEqual(try result.value.title, "Review launch notes")
XCTAssertEqual(try result.value.projectName, "June Release")
}
}
That one test buys you more than it looks like.
It verifies that the intent is visible to the App Intents runtime, that parameter conversion works, that the app can execute the intent out of process, and that the result is represented in the shape the system receives. It is not a UI test in the usual tap-through-the-screen sense. It is an integration test for the contract your app exposes to Shortcuts, Siri, Spotlight, widgets, and other system surfaces.
If an intent takes an AppEnum, pass the raw string value. If it takes an AppEntity, you can often pass a string and let the runtime use the entity’s query to resolve it, which is exactly the behavior you want to test before blaming Siri.
func testMoveTaskBetweenProjects() async throws {
let createTask = definitions.intents["CreateTaskIntent"]
let moveTask = definitions.intents["MoveTaskIntent"]
let created = try await createTask.makeIntent(
title: "Draft release checklist",
project: "Inbox"
).run()
let moved = try await moveTask.makeIntent(
task: created.value,
destinationProject: "June Release"
).run()
XCTAssertEqual(try moved.value.title, "Draft release checklist")
XCTAssertEqual(try moved.value.projectName, "June Release")
}
That shape mirrors how people build Shortcuts: one action produces a value and another action consumes it.
Test entity search before testing pickers
A lot of “Shortcuts can’t find my thing” bugs are really entity query bugs.
If the user adds an action in Shortcuts and searches for a task, Shortcuts is leaning on your entity query. If Siri hears “add a task to June Release”, it needs to resolve “June Release” into a real entity. If your query returns nothing, returns too much, or sorts poorly, the user experience gets weird before your intent’s perform() method even starts.
With AppIntentsTesting, test the entity definition directly:
final class TaskEntityQueryTests: XCTestCase {
private let app = XCUIApplication()
private var definitions: IntentDefinitions!
override func setUp() {
super.setUp()
app.launch()
definitions = IntentDefinitions(
bundleIdentifier: "com.example.TaskBox"
)
}
func testTaskStringQueryFindsKnownTask() async throws {
_ = try await definitions.intents["SeedTaskFixturesIntent"]
.makeIntent()
.run()
let taskEntity = definitions.entities["TaskEntity"]
let results = try await taskEntity.entities(
matching: "launch notes"
)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(try results[0].title, "Review launch notes")
}
}
The production code under test is still your EntityStringQuery implementation:
Identifier lookup deserves its own tiny test. String search is what powers pickers and natural text resolution; identifier lookup is what lets a resolved entity survive handoff between system surfaces.
func testTaskIdentifierLookupFindsExactTask() async throws {
let seed = try await definitions.intents["SeedTaskFixturesIntent"]
.makeIntent()
.run()
let taskEntity = definitions.entities["TaskEntity"]
let reference = try taskEntity.makeReference(
identifier: seed.value.firstTaskIdentifier
)
let results = try await taskEntity.entities(
identifiers: [reference]
)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(try results[0].title, "Review launch notes")
}
Your exact seed result shape will be app-specific. The important split is not: test search text and stable identifier lookup separately.
import AppIntents
struct TaskEntityQuery: EntityStringQuery {
func entities(for identifiers: [TaskEntity.ID]) async throws -> [TaskEntity] {
try await TaskStore.shared.tasks(for: identifiers).map(\.entity)
}
func suggestedEntities() async throws -> [TaskEntity] {
try await TaskStore.shared.recentTasks().map(\.entity)
}
func entities(matching string: String) async throws -> [TaskEntity] {
try await TaskStore.shared.searchTasks(matching: string).map(\.entity)
}
}
This is where the current Siri AI and App Schemas confusion gets more grounded. A custom entity that does not map to a published schema may not get every new Apple Intelligence behavior. During the beta cycle, that boundary is exactly what developers are asking Apple to clarify. But even for fully custom App Intents, Shortcuts and other App Intents surfaces still depend on your intent and query definitions being correct.
So test the query. It is useful either way.
Use test-only intents to control state
Intent tests need reliable state. Do not depend on whatever tasks happened to be in the simulator from yesterday.
Add small, focused intents that exist only for tests:
#if DEBUG
import AppIntents
struct SeedTaskFixturesIntent: AppIntent {
static let title: LocalizedStringResource = "Seed Task Fixtures"
static let isDiscoverable = false
func perform() async throws -> some IntentResult {
try await TaskStore.shared.replaceAll([
Task(title: "Review launch notes", projectName: "June Release"),
Task(title: "Book design review", projectName: "Website"),
Task(title: "Clean up onboarding copy", projectName: "App")
])
return .result()
}
}
#endif
isDiscoverable = false keeps the intent out of user-facing surfaces. #if DEBUG keeps it out of release builds.
This is not just for seed data. A test-only intent can clear local state, create a specific account fixture, open a specific screen, or simulate a migration state. Keep these intents boring and explicit. They are there to make the integration deterministic, not to become a second private automation API.
Test Spotlight indexing
Spotlight bugs are easy to miss because the app can look correct while the index is stale, incomplete, or empty.
If creating a task is supposed to donate it to Spotlight, test the donation from the system side:
final class TaskSpotlightTests: XCTestCase {
private let definitions = IntentDefinitions(
bundleIdentifier: "com.example.TaskBox"
)
func testCreatedTaskIsIndexedInSpotlight() async throws {
_ = try await definitions.intents["ResetTaskFixturesIntent"]
.makeIntent()
.run()
let taskEntity = definitions.entities["TaskEntity"]
let before = try await taskEntity.spotlightQuery(
"Quarterly roadmap review"
)
XCTAssertTrue(before.isEmpty)
_ = try await definitions.intents["CreateTaskIntent"]
.makeIntent(
title: "Quarterly roadmap review",
project: "Planning"
)
.run()
let after = try await taskEntity.spotlightQuery(
"Quarterly roadmap review"
)
XCTAssertEqual(after.count, 1)
XCTAssertEqual(try after[0].title, "Quarterly roadmap review")
}
}
This catches a very real class of regression: the app record exists, but the call that indexes or reindexes the entity was removed, skipped, or pointed at the wrong identifier.
For Siri AI, this matters because indexed entities are part of how the system can find and reason over app content. For plain Spotlight, it matters because search results are a user-facing feature. For Shortcuts, it matters because a well-modeled entity ecosystem tends to make parameter resolution more predictable.
Test view annotations
View annotations are the bridge between visible UI and system understanding.
If the user is looking at a task and says “mark this complete”, Siri needs to know which entity “this” refers to. In SwiftUI, that usually means the visible view needs the right entity identifier:
import AppIntents
import SwiftUI
struct TaskRow: View {
let task: Task
var body: some View {
Text(task.title)
.appEntityIdentifier(
EntityIdentifier(
for: TaskEntity.self,
identifier: task.id
)
)
}
}
The bug you are looking for is wonderfully mundane: wrong id, missing annotation, annotation on the wrong row, annotation not present after navigation, or a reused view accidentally exposing stale data.
Test it by using an intent to get to the screen, a small amount of XCUITest to confirm the UI is actually there, and then viewAnnotations() to inspect what the system sees:
import AppIntentsTesting
import XCTest
final class TaskViewAnnotationTests: XCTestCase {
private let app = XCUIApplication()
private var definitions: IntentDefinitions!
override func setUp() {
super.setUp()
app.launch()
definitions = IntentDefinitions(
bundleIdentifier: "com.example.TaskBox"
)
}
func testTaskDetailAnnotatesVisibleTask() async throws {
_ = try await definitions.intents["SeedTaskFixturesIntent"]
.makeIntent()
.run()
_ = try await definitions.intents["OpenTaskIntent"]
.makeIntent(task: "Review launch notes")
.run()
let title = app.staticTexts["Review launch notes"]
XCTAssertTrue(title.waitForExistence(timeout: 5))
let annotations = try await definitions.entities["TaskEntity"]
.viewAnnotations()
XCTAssertEqual(annotations.count, 1)
XCTAssertEqual(try annotations[0].entity.title, "Review launch notes")
}
}
This is the kind of test that prevents a frustrating Siri bug later. If viewAnnotations() says the visible entity is wrong, Siri’s contextual behavior has bad input. Fix the annotation before touching prompts, phrases, or Siri behavior.
What each surface can see
Here is the practical mental model I would use while the iOS 27 beta APIs and documentation settle.
Shortcuts can see the intents, parameters, enums, and entities you expose through App Intents. If your action appears but parameter search is bad, look at EntityStringQuery, suggested entities, identifier lookup, and display representations.
Spotlight can see what you donate or index for Spotlight. If your app record exists but search does not find it, test the indexing path with spotlightQuery() and verify identifiers, domains, metadata, and reindexing behavior.
Siri AI builds on App Intents, App Entities, App Schemas, indexed content, and view annotations. If your domain maps to a published App Schema, use it. If it does not, be cautious about claiming the full new Siri AI experience. You can still make strong App Intents and Shortcuts support, model your entities cleanly, provide EntityStringQuery fallback behavior when indexing is not right, index appropriate content when semantic lookup matters, and annotate visible UI. That work is not wasted.
The mistake is treating Siri as the first debugger. Treat Siri as the final acceptance test after the underlying contracts pass.
A sane test ladder
I would put the tests in this order:
- Unit test the business logic behind the intent in your app target.
- Use
AppIntentsTestingto run each important intent withmakeIntent(...).run(). - Test entity lookup with
entities(matching:), identifier lookup, and suggested entities. - Test Shortcuts-shaped composition by passing one intent’s result into another.
- Test Spotlight indexing with
spotlightQuery(). - Test visible context with
viewAnnotations(). - Open the Shortcuts app and manually inspect the action UI, parameter pickers, summaries, and composed workflows.
- Test the real Siri voice flow last.
That ladder keeps each failure close to the layer that caused it.
It also gives you a more honest answer to the big developer question from this WWDC cycle. “Can Siri do this?” is not one question. It is several contracts stacked together. With AppIntentsTesting, you can prove the contracts your app owns before you start evaluating the parts owned by Siri’s language understanding and the current schema system.
Further reading: