A Shortcut works when you tap it in the debugger. A week later, someone searches for the same note in Spotlight and finds nothing. The note exists; the intent even returned it. The missing step was indexing, and an ordinary unit test never crossed that boundary.
Apple introduced AppIntentsTesting at WWDC26 to test the path that Siri and Shortcuts use to discover and execute your app’s intents. Its tests run out of process, address the installed app by bundle identifier, and can query system integrations such as Spotlight and view annotations. You still need your fast domain tests and occasional hands-on checks. This framework fills the gap between them.
We’ll use a notes app with CreateNoteIntent, NoteEntity, and OpenNoteIntent. The names are examples: substitute the actual intent and entity type names from your app. If you’re still designing those types, start with an App Intents architecture that keeps business logic in a shared use case. This guide focuses on validating that architecture from outside the app process.
1. Set up the test target deliberately
Use Xcode 27 with the iOS 27 SDK and a matching simulator or device runtime. Apple released Xcode 27 and iOS 27.0 on September 14, 2026. The App Intents Testing documentation still carries a beta label as of this writing; that label describes the documentation’s API status, not the release status of Xcode or iOS. Check the signatures in your installed SDK when upgrading CI.
Add a UI Testing Bundle to your Xcode project, or use the UI test target you already have. In Xcode’s target settings, select the app under test and the same development team for both targets. Apple’s WWDC26 walkthrough explicitly calls out that code-signing requirement. Add AppIntentsTesting to the UI test target and import it alongside XCTest. You don’t import the app module into this target.
That distinction matters. A unit test that calls CreateNoteIntent.perform() directly can prove its Swift implementation behaves under a local test harness. An AppIntentsTesting test asks the installed app to resolve the intent and run it across a process boundary. It can catch a missing definition, a parameter conversion problem, or an extension-host dependency that a direct call missed. See Swift Testing for fast domain tests for the other side of that test strategy.
The test target can identify your app like this:
import AppIntentsTesting
import Foundation
import XCTest
final class NoteIntentTests: XCTestCase {
private let definitions = IntentDefinitions(
bundleIdentifier: "com.example.Notes"
)
func testCreateNoteReturnsItsEntity() async throws {
let create = definitions.intents["CreateNoteIntent"]
let result = try await create.makeIntent(
title: "Packing list"
).run()
XCTAssertEqual(try result.value.title, "Packing list")
}
}
Replace com.example.Notes with your installed app’s bundle identifier. This example assumes the intent has a title parameter and returns a NoteEntity with a title property. makeIntent uses the parameter names exposed by the intent, and result.value.title reads a property from its type-erased result. These names are strings and dynamic members, so the compiler cannot catch a typo in your app’s type name or parameter label. The test will catch it at runtime. Apple’s IntentDefinitions documentation describes this lookup model.
2. Make test state predictable
An integration test that depends on yesterday’s notes is a future flaky test. Give the app a controlled way to reset and seed fixtures. Apple recommends a test-only intent: an ordinary AppIntent hidden from discovery and compiled only into a debug build.
The following is the app-side shape. NoteStore and replaceAllForUITests() stand for your own test storage adapter; those are not framework APIs.
#if DEBUG
import AppIntents
struct SeedNoteFixturesIntent: AppIntent {
static var title: LocalizedStringResource = "Seed note fixtures"
static var isDiscoverable: Bool = false
func perform() async throws -> some IntentResult {
try await NoteStore.shared.replaceAllForUITests(
titles: ["Design review", "Packing list"]
)
return .result()
}
}
#endif
Keep that adapter narrow and available only in a test build. Reset both the records and any associated Spotlight entries when your app owns that index; otherwise an old indexed note can outlive the record and confuse the next test. Use a dedicated test account or container so a simulator reset cannot erase real user data. If the app and an extension share persistence, the fixture writer must obey the same process and migration rules as production code; sharing a SwiftData store across app and extension processes explains that boundary.
Call the seed intent at the start of each test that needs a known list:
private func seedNotes() async throws {
let seed = definitions.intents["SeedNoteFixturesIntent"]
_ = try await seed.makeIntent().run()
}
That helper belongs inside the NoteIntentTests class above. It deliberately does not launch the UI. Intent execution itself should work whether or not someone has the app on screen. Put the helper behind a debug test scheme and never add the test-only intent to a release build.
3. Test entity lookup, not just intent execution
Shortcuts may show suggested notes before the user types. Once they start typing, your entity’s string query must return the right match. A working CreateNoteIntent proves neither path.
Add a test to the same class:
func testNoteStringQueryFindsSeededNote() async throws {
try await seedNotes()
let note = definitions.entities["NoteEntity"]
let matches = try await note.entities(matching: "Design")
XCTAssertEqual(matches.count, 1)
XCTAssertEqual(try matches[0].title, "Design review")
}
func testSuggestedNotesIncludeSeededNote() async throws {
try await seedNotes()
let note = definitions.entities["NoteEntity"]
let suggestions = try await note.suggestedEntities()
let titles = try suggestions.map { try $0.title as String }
XCTAssertTrue(titles.contains("Design review"))
}
Apple documents entities(matching:) and suggestedEntities() on AppEntityDefinition. If entities(matching:) fails because your query only implements identifier lookup, add EntityStringQuery to the app’s query type and implement its text search. Do not make the test call the repository directly: the framework’s value here is checking how the system resolves the entity.
Check identifiers too when your app passes entities between intents. Rename and delete workflows are particularly good candidates: the displayed title can change, but the identifier must continue to resolve to the same record. If your query returns two notes with the same title, test with stable identifiers rather than relying on array order. Also verify how your query handles an identifier that no longer exists; Siri can retain a reference after the underlying data is deleted.
4. Exercise a Shortcut-shaped workflow
People compose actions. A successful create operation may return an entity that a later intent consumes. Test that handoff as a single journey, while keeping each test’s initial data isolated.
Suppose your app provides RenameNoteIntent with note and newTitle parameters. The test can pass the entity returned by the first intent into the next one:
func testCreateThenRenameNote() async throws {
try await seedNotes()
let create = definitions.intents["CreateNoteIntent"]
let created = try await create.makeIntent(
title: "Release draft"
).run()
XCTAssertEqual(try created.value.title, "Release draft")
let rename = definitions.intents["RenameNoteIntent"]
let renamed = try await rename.makeIntent(
note: created.value,
newTitle: "Release notes"
).run()
XCTAssertEqual(try renamed.value.title, "Release notes")
}
This assumes both intents return a NoteEntity. Apple’s WWDC26 AppIntentsTesting session demonstrates the same pattern with a created event passed into an update intent. It is useful precisely because it validates runtime parameter conversion and entity identity, not only two independent perform() methods.
Keep negative paths alongside the happy path. What happens when a note is deleted between create and rename? Does the intent surface a useful error if an account is signed out? Does it avoid producing a second note after a retry? Those cases are usually easier to make deterministic with a test-only fixture intent than with UI taps.
5. Verify Spotlight really received the entity
Your notes list can show a new record even when Spotlight never indexes it. An AppIntentsTesting query checks the system’s index for your entity type:
func testCreatedNoteAppearsInSpotlight() async throws {
try await seedNotes()
let token = "IndexProbe-\(UUID().uuidString)"
let entity = definitions.entities["NoteEntity"]
let create = definitions.intents["CreateNoteIntent"]
let result = try await create.makeIntent(title: token).run()
XCTAssertEqual(try result.value.title, token)
let deadline = Date().addingTimeInterval(10)
while Date() < deadline {
let hits = try await entity.spotlightQuery(token)
if hits.contains(where: { hit in
(try? hit.title as String) == token
}) {
return
}
try await Task.sleep(for: .milliseconds(250))
}
XCTFail("Created note did not appear in Spotlight within 10 seconds")
}
spotlightQuery(_:) is documented on AppEntityDefinition. The UUID gives each run a fresh search term, and the bounded poll accommodates indexing latency; it does not excuse an indefinitely missing result. The example assumes CreateNoteIntent accepts an arbitrary title and indexes the created note. Clear the fixture’s index during setup as well. Avoid asserting the exact count unless your index reset is guaranteed, because duplicate results or stale entries can reveal an isolation problem rather than an indexing failure.
This test assumes your app’s create operation indexes NoteEntity. If the app intentionally indexes in a later batch, test that batch’s documented completion point instead of imposing a ten-second deadline on work that was never promised to be immediate. A Spotlight regression test should reflect your product’s freshness contract.
When that index also grounds generated answers, test the full Core Spotlight RAG pipeline for retrieval quality, stale records, visible sources, and unsupported claims.
6. Check the entity the system sees on screen
View annotations give Siri context for phrases such as “summarize this note.” A detail screen can display the right text while annotating the wrong entity identifier. You can verify both the screen and the system-facing annotation.
For this test, the app defines a debug-only OpenNoteIntent that navigates to a known note, or exposes an equivalent production intent. The test first confirms the UI reached the destination, then reads annotations:
func testDetailScreenAnnotatesTheCorrectNote() async throws {
try await seedNotes()
let app = XCUIApplication()
app.launch()
let open = definitions.intents["OpenNoteIntent"]
_ = try await open.makeIntent(title: "Design review").run()
let heading = app.staticTexts["Design review"]
XCTAssertTrue(heading.waitForExistence(timeout: 5))
let note = definitions.entities["NoteEntity"]
let annotations = try await note.viewAnnotations()
let visibleTitles = try annotations.map {
try $0.entity.title as String
}
XCTAssertTrue(visibleTitles.contains("Design review"))
}
The OpenNoteIntent example assumes a title parameter that uniquely selects the fixture. For production data, pass a stable identifier or entity instead. Apple’s ViewAnnotation API exposes the entity and selection state; Apple’s session shows viewAnnotations() revealing an incorrect identifier even when the screen looked correct.
This is the one place where a small UI assertion helps. It establishes that you are querying the annotation for the intended screen. Avoid turning the test into a long navigation script. If navigation fails, you want the failure to point to opening the note; if the annotation fails, you want to investigate the entity association.
7. Make the suite useful in CI
Run the UI test target against an iOS 27 simulator, or a dedicated device on the corresponding release. Pin the Xcode version and runtime image in CI so a toolchain or OS update does not silently change the test environment. Install the app and run the test bundle through the same Xcode test action you use for other XCUITests; AppIntentsTesting needs no separate test runner.
Keep the fixture suite small. Each out-of-process call does real system work, so it is slower than a pure repository test. Use your Swift Testing suite for validation rules and query algorithms, then reserve these tests for registration, parameter resolution, cross-intent handoff, Spotlight, and annotations. Prefer one clear assertion per system behavior over dozens of cases that all traverse the same stack.
Do not share a mutable test account across parallel destinations. Two tests seeding the same app storage or Spotlight domain can invalidate each other’s preconditions. Give each worker its own simulator and data container, or serialize the integration suite. When a test fails, preserve the Xcode result bundle and log the bundle identifier, runtime, intent type name, and fixture token. Those details make a system-level failure reproducible.
There are limits to what this framework proves. It executes through the App Intents stack and can inspect selected integrations; it does not certify every Siri phrase, language, suggestion ranking, permission prompt, or Shortcuts presentation. After the automated suite passes, use Siri and the Shortcuts app manually on a representative device. Apple’s WWDC26 guidance places that hands-on check after automated intent and integration coverage, not outside the workflow.
Key Takeaways
- Put
AppIntentsTestingin a UI Testing bundle signed with the same team as the app; identify the app withIntentDefinitions(bundleIdentifier:). - Seed isolated fixtures through a debug-only, undiscoverable intent. The tests should not depend on the user’s previous app state.
- Test intent results, string queries, suggestions, and entity handoffs as separate observable behaviors.
- Query Spotlight and view annotations to catch failures that a direct call to
perform()cannot see. - Keep fast domain tests and a brief manual Siri and Shortcuts pass alongside the out-of-process suite.
The payoff is a test that fails where the integration fails. When a note exists but Spotlight cannot find it, your suite says so before a user does.