Skip to content

Testing App Intents: The Half of the Migration Nobody Writes

The migration guides all stop at the point your App Intent compiles. That's the moment the interesting failures begin and they're silent, which is why Apple shipped a framework specifically to catch them.

Share
A dim hub with glowing conduits radiating outward, two of them broken partway along leaving their destinations unlit

Guidance on migrating from SiriKit to App Intents is everywhere. Inventory your intents, map them, rewrite the extension, and delete the old code. Every guide I've read ends at roughly the same place: your new AppIntent compiles.

That's a strange place to stop, because it's precisely where the interesting failures begin. An App Intent that compiles is not an App Intent the system can see, resolve, or invoke, and the difference between those two states produces no error, no crash, and no log line. Your app simply isn't there.

Apple shipped a framework at WWDC 2026 aimed squarely at this gap. It's had almost no coverage next to the migration itself, which is the wrong way round.

In this post:

Silent Failure Is the Whole Problem

Most bugs announce themselves. A crash produces a report. A failed network call produces an error. A layout bug is visible to anyone who looks at the screen.

An App Intent the system can't resolve produces none of that. The build succeeds. The app launches. Every existing test passes, because your existing tests exercise your app's own code and the intent's perform() method probably works fine in isolation. The only symptom is an absence. The action doesn't appear in Shortcuts, the entity doesn't come back from a picker, or Siri says it can't help.

And absences don't get reported. Nobody files a bug that says "your app didn't appear when I asked for it." They try once, it doesn't work, and they stop trying. You find out months later from a support ticket, or you never find out at all.

That's the worst failure class there is, and it's the reason this deserves more attention than a migration checklist. Writing the code takes a week. Knowing it works has no obvious answer, which is exactly what the framework is for.

What AppIntentsTesting Actually Is

AppIntentsTesting is a framework for running and testing your app intents, entities, enums, and query logic out of process, the same way Siri or Shortcuts perform them. It provides type-erased APIs that let you reference intents by name, set their parameters, and run them without linking against your app target.

It's available across the board: iOS 27, iPadOS 27, macOS 27, tvOS 27, watchOS 27, visionOS 27 and Mac Catalyst 27.

The public surface is small and readable:

  • IntentDefinitions:: a collection cataloguing your app's intents, enums, entities and queries
  • ResolvedIntentResult: a type-safe result from performing an app intent
  • ResolvedValueQueryResult: the result of an intent value query
  • ViewAnnotation: the onscreen context you provide by annotating a view with an app entity
  • AnyAppIntent, AnyAppEntity, AnyEntityQuery, AnyAppEnum, AnyTransientAppEntity: type-erased intermediate representations for testing
  • DynamicPropertyPath and friends: type-safe dynamic access to nested intent values

Apple's own session on it is Validate your App Intents adoption with AppIntentsTesting (WWDC26, session 295), delivered by an engineer on the App Intents team. It's worth watching before you write any of this, because the framework's shape only makes sense once you see what it deliberately refuses to do.

The Design Decision Worth Noticing

Read that description again: type-erased APIs, referencing intents by name, without linking against your app target.

There is no @testable import YourApp here. There can't be. You address your intents and entities by string name, through the same infrastructure the system uses, from a test runner process that is not your app.

Most testing frameworks trend in the opposite direction. The whole history of unit testing on Apple platforms has been about getting more access to the thing under test. @testable exposes internal symbols, dependency injection to reach private state, mocks standing in for collaborators. Convenient, and it makes tests easy to write.

This framework enforces the black box on purpose, and the reason is worth sitting with. If your test target imports your app module and calls the intent's perform() directly, you can prove your business logic works while the App Intents surface. The thing the system actually talks to is completely broken. Your test passes. Siri still can't see you. That's a test that fails at its only job, and the framework's design makes it impossible to write.

I've argued before that a test which passes for the wrong reason is worse than no test, because it converts an unknown into a false certainty. It's unusual and rather good to see a framework designed so that particular mistake can't be made.

There is a cost, and it's fair to name it. Referring to intents, entities and parameters by string makes these tests more brittle than ordinary unit tests; rename a parameter, and you get a runtime failure rather than a compile error. That's the trade you're making for a genuinely realistic integration test. I think it's the right trade here, precisely because the string names are the contract. If a rename breaks your test, it also broke your Shortcuts actions.

Where the Failures Actually Live

When something doesn't work, the instinct is to debug it through Siri. Say the phrase, watch it fail, change something, say it again.

That's the most expensive possible way to find these bugs, because by the time a voice request fails, several independent things have already happened: the language was interpreted, an intent was selected, entities were resolved against your query, and your code ran. A failure anywhere in that chain looks identical from the outside, and Siri says it can't help.

Worse, only some of those layers are yours. Apple handles language interpretation, and now partly so does a large language model. You cannot test it, you cannot make it deterministic, and no amount of staring at it will tell you whether the problem is your entity query or the model's understanding of the phrase.

So the sensible split is to prove the layers you own, and treat the ones you don't as the last step rather than the first. Concretely, the questions worth answering in order:

  • Can the intent execute out-of-process at all?
  • Can the system resolve my entities by identifier, and by search text?
  • Does Spotlight actually contain what I think I indexed?
  • Does the system see the right entity for what's currently on screen?

Only after those pass does "does Siri understand this phrase" become a meaningful question, because now a failure means something specific rather than "somewhere in five layers, something went wrong."

Entity queries deserve particular attention. A large share of "Shortcuts can't find my thing" reports are entity query bugs rather than intent bugs. If your query returns nothing, returns too much, or sorts badly, the experience degrades before perform() is ever called, and it degrades in a way that looks like Siri being stupid rather than your query being wrong.

A Test Ladder

Roughly the order I'd work in:

  1. Unit test the business logic behind the intent, in your app target, as normal. This layer hasn't changed.
  2. Run each significant intent through AppIntentsTesting and assert on the result.
  3. Test entity lookup separately: search-text resolution and identifier lookup are different code paths with different failure modes. Search powers pickers and natural language; identifier lookup is what lets a resolved entity survive being handed between system surfaces.
  4. Test composition: pass one intent's result into another. That's the shape people actually build in Shortcuts, and it exercises entity handoff.
  5. Test Spotlight indexing: the app record and the index containing it are different facts.
  6. Test view annotations: if a user says "this one" while looking at a row, the system needs the right identifier attached to that row.
  7. Open Shortcuts manually and look at your action's parameter pickers and summaries.
  8. Test the real Siri flow last, as acceptance rather than diagnosis.

Each rung keeps failures close to the layer that caused them, which is the entire point.

What This Looks Like in Code

A caveat first, and please take it seriously. The framework shipped with the iOS 27 beta cycle. The type names below come from Apple's published documentation, but the exact method shapes follow the WWDC session and early SDK, and Apple has been known to adjust these before general release. Check every symbol against the SDK you're actually building against rather than trusting a blog post, including this one.

Tests live in a UI Testing bundle, not a unit test target, and the test runner and app target need to be signed with the same development team so they can communicate on device. That catches people out, because nothing about this feels like a UI test in the tap-through-the-screen sense.

The general shape is: build an IntentDefinitions for your app's bundle identifier, look up an intent by name, populate it, run it, assert on the result.

import AppIntentsTesting
import XCTest

final class EpisodeIntentTests: XCTestCase {

    private let app = XCUIApplication()
    private var definitions: IntentDefinitions!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app.launch()
        definitions = IntentDefinitions(
            bundleIdentifier: "uk.co.example.Podcatcher"
        )
    }

    func testQueueEpisodeReturnsQueuedEpisode() async throws {
        let queueEpisode = definitions.intents["QueueEpisodeIntent"]

        let result = try await queueEpisode.makeIntent(
            episode: "The Long Now",
            position: "next"
        ).run()

        XCTAssertEqual(try result.value.title, "The Long Now")
    }
}

Note what that single test covers, which is more than it appears: the intent is visible to the App Intents runtime, parameter conversion works, the app can execute it out-of-process, and the result is shaped the way the system will receive it. That's four separate contracts in one assertion.

Seed your state deliberately. Out-of-process testing means you can't reach in and set up fixtures the usual way. The workable pattern is a small intent that exists only for testing — marked non-discoverable so it stays out of user-facing surfaces, and wrapped in #if DEBUG so it never ships.

Keep those boring. The temptation, once you have a mechanism for driving your app from outside, is to grow it into a general-purpose private automation API. Resist that: every test-only intent is production code that has to keep compiling, and a sprawl of them becomes its own maintenance surface.

Don't Forget macOS

Nearly all the coverage of this is iOS-shaped, and the framework is available on macOS 27 alongside every other platform.

That's worth acting on, not just noting. Mac apps have had Shortcuts support since Monterey and Spotlight integration for far longer, and both are arguably more valuable per user on the desktop. Spotlight is how a large share of Mac users navigate their machine, and Shortcuts on macOS composes with shell scripts and automation in ways it can't on iPhone.

If you maintain apps on both platforms with shared intent definitions, the entity queries and Spotlight donation paths are the same code and the tests transfer directly. If you maintain a Mac-only app, this is probably the first time you've had a reasonable way to verify your Spotlight indexing works, and it's worth the afternoon regardless of anything happening with Siri.

My Take

The silent failure is what makes this urgent, not the deprecation. The SiriKit migration has a deadline narrative attached, and deadlines make people move. But a deadline you miss is at least visible. An intent that compiles and can't be resolved is invisible on both sides; you don't know it's broken, and the user doesn't know it should have worked. If I had to pick one reason this post exists rather than another migration checklist, it's that the migration will get done because it's on someone's board. The verification won't, because nothing will tell you it's missing.

The no-@testable-import decision is the part I find genuinely interesting. Apple shipped a testing framework deliberately incapable of seeing inside the thing it tests, and I think that's correct in a slightly uncomfortable way. Every convenient testing tool we've built on this platform has moved toward more access, and the cost has been a category of test that verifies your implementation while the contract rots. Forcing the string-name, out-of-process route makes those tests impossible to write. It'll produce more brittle tests. It'll also produce tests that mean something.

Be honest about the layer you can't test, because pretending otherwise is how people waste weeks. Natural language understanding is not deterministic and never will be. You cannot write a test that proves Siri will interpret a phrase correctly, and chasing that is a good way to spend a fortnight adjusting phrases with no way to tell whether anything improved. What you can prove is everything underneath: that the intent runs, the entity resolves, the index contains the record, the visible row carries the right identifier. Prove those, and when the voice flow still fails, you've narrowed the problem to the one layer that's genuinely Apple's. That's not a smaller ambition; it's the only honest one.

And the practical note for anyone reading this in the middle of a migration: don't wait until the port is finished to start testing it. The framework works against intents you've already migrated, and running it early means you discover your entity query returns nothing on the second intent rather than the twentieth. The alternative is finishing the migration, shipping, and finding out from an absence.

FAQ

What is AppIntentsTesting?
A framework introduced at WWDC 2026 for running and testing app intents, entities, enums and query logic out-of-process, the same way Siri or Shortcuts perform them, plus verifying integration with system features like Spotlight.

Which platforms support it?
iOS 27, iPadOS 27, macOS 27, tvOS 27, watchOS 27, visionOS 27 and Mac Catalyst 27.

Do these go in a unit test target?
No — a UI Testing bundle, with the test runner and app target signed by the same development team.

Can I use @testable import with it?
No, and that's deliberate. The framework uses type-erased APIs that reference intents by name without linking against your app target, so you're testing the surface the system sees rather than your app's internals.

Can I test whether Siri understands a phrase?
No. Language interpretation isn't yours and isn't deterministic. Test the layers below it — intent execution, entity resolution, indexing, annotations — and treat the voice flow as a final acceptance check.

Does this replace manual Siri testing?
No. It gives you a lower, faster, deterministic layer to test first, so that when you do test by voice, a failure points to something specific.