swift-assist: An AI-Powered iOS Testing Skill for Claude Code
You've got an iOS app. It has a home tab, a favorites tab, a settings page, maybe a detail view. Every one of those screens has buttons, text fields, toggles, and navigation links that users interact with. How many of those elements have accessibility identifiers? If you're being honest, probably not enough.
And here's the thing: accessibility identifiers aren't just for accessibility. They're how automated tests find elements reliably. Without them, your tests resort to coordinate-based tapping, which breaks the moment you change a font size or add a button. Every missing identifier is a test you can't write, or a test that will be flaky if you do.
I built swift-assist to solve this. It's a Claude Code skill that uses Grantiva CLI and computer use to walk your entire app in the simulator, find every element missing an accessibility identifier, fix them automatically, generate test flows for every screen, and run visual regression testing. The whole pipeline, from zero identifiers to full test coverage, driven by AI that can actually see and interact with your app.
I ran it against the Landmarks example app to show you what that looks like in practice.
What You Need
Install the Grantiva CLI:
brew install grantiva/tap/grantiva
Then install the skill:
npx skills add grantiva/swift-assist
Computer use is optional but recommended. Without it, the skill relies on grantiva runner dump-hierarchy and source code analysis to discover elements. With computer use enabled, the skill can also visually navigate your app in the simulator the same way a human would, catching things the hierarchy alone might miss.
The Workflow
The skill provides 14 commands that build on each other. You can run them individually or chain them together. Here's the full pipeline:
/swift-assist:init # Set up Grantiva, build, launch in sim
/swift-assist:doctor # Find missing accessibility IDs
/swift-assist:add-identifiers # Apply fixes to Swift source files
/swift-assist:setup-mocks # Add mock services for deterministic testing
/swift-assist:make-tests --all # Generate test flows for every screen
/swift-assist:test --all # Run the tests
/swift-assist:vrt --baseline # Capture visual baselines
That takes you from an untested app to full flow coverage with visual regression baselines. Let's walk through each one using the real Landmarks app.
Init: Build and Launch
/swift-assist:init --scheme=Landmarks
The init command finds your Xcode project, runs grantiva doctor to verify your environment, creates a grantiva.yml config, selects a simulator, and builds and launches your app. If you already have a booted simulator, it uses that. If not, it picks a reasonable iPhone and boots it for you.
Here's what grantiva doctor reports for the Landmarks project:
Xcode ✓ Xcode 26.2
Booted Simulator ✓ iPhone 17 Pro - iOS-26-2
Driver Cache ✓ Cached for Xcode 26.2
grantiva.yml ✓ Found
Git Repository ✓ Detected
Grantiva Auth ✓ Authenticated
All green. The app is running in the simulator and ready for analysis.
Doctor: Find What's Missing
/swift-assist:doctor
This is where it gets interesting. The doctor command does two things simultaneously: it scans your Swift source code to catalog every interactive element, and it uses computer use to walk your app screen by screen in the simulator.
Computer use means Claude is actually looking at your app the way a user would. It taps through tabs, follows navigation links, opens sheets and modals, and screenshots every screen it finds. At each screen, it uses grantiva runner dump-hierarchy to query the live view hierarchy and see exactly what identifiers exist.
Here's what doctor found in the Landmarks app - 54 interactive elements across 11 view files, and not a single accessibility identifier on any of them:
Accessibility Doctor Report
============================
Scanned: 11 views, 54 interactive elements
Missing identifiers: 54 (0% coverage)
ContentView (5 missing)
- Tab "Landmarks" -> content-tab-landmarks
- Tab "Favorites" -> content-tab-favorites
- Tab "Grantiva" -> content-tab-grantiva
- Tab "Feedback" -> content-tab-feedback
- Tab "Deep Links" -> content-tab-deeplinks
LandmarkDetailView (3 missing)
- NavigationLink (category) -> landmark-detail-link-category
- NavigationLink "Plan Visit" -> landmark-detail-link-plan-visit
- Button (favorite toggle) -> landmark-detail-button-favorite
EditLandmarkView (4 missing)
- TextField "Name" -> edit-landmark-textfield-name
- TextField "Location" -> edit-landmark-textfield-location
- Button "Cancel" -> edit-landmark-button-cancel
- Button "Done" -> edit-landmark-button-done
GrantivaView (7 missing)
- Button "Validate" -> grantiva-button-validate
- Button "Refresh" -> grantiva-button-refresh
- Button "Clear" -> grantiva-button-clear
- Button "Clear Identity" -> grantiva-button-clear-identity
- TextField "User ID" -> grantiva-textfield-user-id
- Button "Identify" -> grantiva-button-identify
- Button "Fetch Flags" -> grantiva-button-fetch-flags
DeepLinksView (14 missing)
- NavigationLink "Caching" -> deeplinks-link-caching-demo
- Button "Edit Landmark" -> deeplinks-button-edit-landmark
- Button "Show as Sheet" -> deeplinks-button-show-sheet
- Button "Save State" -> deeplinks-button-save-state
... and 10 more
Coverage: 0/54 elements have identifiers (0%)
Every identifier follows a consistent convention: <screen>-<elementType>-<descriptor>. The screen name comes from the SwiftUI view struct. The element type maps to the component. The descriptor comes from the element's label or purpose.
What the Hierarchy Actually Shows
One thing I learned building this: what you put in the source code and what the XCUITest hierarchy actually shows aren't always the same thing. Tab items are a perfect example. You'd expect .accessibilityIdentifier("content-tab-favorites") on a tab's content view to make that tab tappable by identifier. But grantiva runner dump-hierarchy reveals the truth:
[XCUIElementTypeTabBar] label="Tab Bar"
[XCUIElementTypeButton] name="map" label="Landmarks"
[XCUIElementTypeButton] name="heart" label="Favorites"
[XCUIElementTypeButton] name="shield.checkered" label="Grantiva"
[XCUIElementTypeButton] name="link" label="Deep Links"
The tab buttons use the SF Symbol name as their name attribute and the display text as their label. Your custom identifier ends up on the content wrapper, not the button. So test flows should use tap: "Favorites" (matching the label) for tabs, and save the accessibility identifiers for elements inside the screens where they work reliably:
[XCUIElementTypeButton] name="deeplinks-link-caching-demo"
label="Caching Demo, Test cache policies..."
That deeplinks-link-caching-demo identifier on a NavigationLink works perfectly. The doctor command learns this distinction from the live hierarchy and generates flows that use the right strategy for each element type.
Add Identifiers: Apply the Fixes
/swift-assist:add-identifiers
The add-identifiers command reads the doctor report and edits your Swift files directly. It adds .accessibilityIdentifier() modifiers to every flagged element.
For a NavigationLink:
// Before
NavigationLink(screen: .deepLinks(.servicesDemo)) {
VStack(alignment: .leading) {
Label("Caching Demo", systemImage: "internaldrive")
}
}
// After
NavigationLink(screen: .deepLinks(.servicesDemo)) {
VStack(alignment: .leading) {
Label("Caching Demo", systemImage: "internaldrive")
}
}
.accessibilityIdentifier("deeplinks-link-caching-demo")
For a TextField:
// Before
TextField("Name", text: $name)
// After
TextField("Name", text: $name)
.accessibilityIdentifier("edit-landmark-textfield-name")
In the Landmarks app, this added 37 identifiers across 10 view files. The modifier is always placed last, after visual modifiers, matching whatever indentation style your project uses.
Mock Services for Testing
One thing you'll hit immediately: if your app loads data from an API, the Landmarks tab shows "Unable to Load" when there's no server running. Tests need deterministic data.
Run /swift-assist:setup-mocks and it handles this automatically - it adds the #if UI_TESTING guard to your app entry point and updates grantiva.yml with the right build settings. Or do it manually if you want full control.
The Landmarks app already has a .mock services variant with stateful in-memory stores (from the closure-based DI pattern). We just need to activate it during testing:
@main
struct LandmarksApp: App {
let services: Services
init() {
#if UI_TESTING
services = .mock
#else
services = .live(baseURL: baseURL)
#endif
}
}
Then add build_settings to your grantiva.yml so Grantiva passes the flag through when it builds:
scheme: Landmarks
simulator: iPhone 17 Pro
bundle_id: com.grantiva.examples.landmarks
build_settings:
- "SWIFT_ACTIVE_COMPILATION_CONDITIONS=DEBUG UI_TESTING"
Now grantiva diff capture builds with mock services automatically. No need to touch xcodebuild directly.
Make Tests: Generate Flow YAMLs
/swift-assist:make-tests --all
With accessibility identifiers in place and mock services providing data, make-tests walks the app with computer use to discover every user flow. From that, it generates screen definitions in grantiva.yml:
screens:
- name: Landmarks
path: launch
- name: Favorites
path:
- tap: "Favorites"
- name: DeepLinks
path:
- tap: "Deep Links"
- name: CachingDemo
path:
- tap: "deeplinks-link-caching-demo"
- wait: 1
- name: LandmarkDetail
path:
- tap: "Landmarks"
- wait: 5
- tap: "Golden Gate Bridge"
- wait: 1
- name: VisitConfirmation
path:
- tap: "Landmarks"
- wait: 5
- tap: "Golden Gate Bridge"
- wait: 1
- tap: "Plan Visit"
- wait: 1
Notice the mix: tabs are tapped by label text ("Favorites"), inner elements by accessibility identifier ("deeplinks-link-caching-demo"), and list items by their visible content ("Golden Gate Bridge"). The generated flows use whatever the hierarchy actually exposes for each element.
It also generates standalone Maestro-compatible flow YAML files for individual user journeys:
# flows/plan-visit.yaml
appId: com.grantiva.examples.landmarks
---
- launchApp
- assertVisible: "Landmarks"
- tapOn: "Golden Gate Bridge"
- assertVisible: "Plan Visit"
- tapOn: "landmark-detail-link-plan-visit"
- assertVisible: "Visit Scheduled!"
- takeScreenshot: "Visit Confirmation"
- tapOn: "visit-confirmation-button-done"
- assertVisible: "Landmarks"
Test: Run the Flows
/swift-assist:test --all
Here's the real output from running all 8 screens against the Landmarks app with mock services:
[1/1] flow
────────────────────────────────────────
✓ launchApp (4.3s)
✓ takeScreenshot (100ms)
✓ tapOn: text="Favorites" (1.7s)
✓ takeScreenshot (76ms)
✓ tapOn: text="Grantiva" (1.5s)
✓ takeScreenshot (75ms)
✓ tapOn: text="Feedback" (1.6s)
✓ takeScreenshot (73ms)
✓ tapOn: text="Deep Links" (1.5s)
✓ takeScreenshot (80ms)
✓ tapOn: text="deeplinks-link-caching-demo" (1.5s)
✓ takeScreenshot (83ms)
✓ tapOn: text="Landmarks" (1.6s)
✓ tapOn: text="Golden Gate Bridge" (1.5s)
✓ takeScreenshot (110ms)
✓ tapOn: text="Landmarks" (1.5s)
✓ tapOn: text="Golden Gate Bridge" (1.5s)
✓ tapOn: text="Plan Visit" (1.5s)
✓ takeScreenshot (68ms)
✓ flow 20.5s
25 steps passing (20.5s)
Flow Status Steps Pass Fail Duration
flow ✓ PASS 25 25 0 20.5s
25 steps, all passing, 20.5 seconds. Every tab, every navigation link, the full "browse to detail to plan visit to confirmation" journey, all verified with real taps on real UI elements.
VRT: Visual Regression Testing
/swift-assist:vrt --baseline
With all screens passing, Grantiva captures reference screenshots:
Captured 8 screens in 35.1s
• Landmarks (316.3 KB)
• Favorites (151.2 KB)
• Grantiva (197.8 KB)
• Feedback (142.3 KB)
• DeepLinks (279.9 KB)
• CachingDemo (336.8 KB)
• LandmarkDetail (898.8 KB)
• VisitConfirmation (99.5 KB)
These become your baselines. After making changes, run /swift-assist:vrt without the flag. Grantiva diffs each screenshot pixel-by-pixel using both raw pixel difference and CIE76 perceptual color distance. If either threshold is exceeded, you see exactly what changed.
If the changes are intentional:
/swift-assist:vrt-approve
MCP Server: Real-Time Hierarchy for Agents
The swift-assist skill calls the grantiva CLI to do its work, but there's a more powerful option: the Grantiva MCP server. Instead of the agent calling CLI commands and parsing output, the MCP server gives it direct tools for tapping, swiping, and reading the hierarchy, with real-time updates pushed automatically.
Start it with:
grantiva mcp serve
Or add it to your Claude Code MCP config:
{
"mcpServers": {
"grantiva": {
"command": "grantiva",
"args": ["mcp", "serve"]
}
}
}
The server exposes 16 tools: grantiva_tap, grantiva_swipe, grantiva_type, grantiva_screenshot, grantiva_a11y_tree, grantiva_a11y_check, grantiva_script for batch actions, plus build, test, simulator, VRT, and context tools.
The real feature is the grantiva://hierarchy resource. The agent subscribes once, and after every UI action, the updated accessibility tree is pushed automatically. No polling, no extra commands. The agent taps a button and immediately sees the new screen's elements. This is what makes the doctor workflow fast: navigate to a screen, get the hierarchy pushed to you, analyze it, move on.
There's also grantiva://screenshot if the agent needs a visual of the current screen.
More Skills
The main pipeline covers the happy path. A few other skills are useful when you're iterating:
/swift-assist:build - Rebuild after code changes and install on the simulator. Faster than re-running init when the project is already configured.
/swift-assist:install - Full build-install-launch cycle. Use this when you want the app running and ready to test immediately after a code change.
/swift-assist:run-flow - Run a single named flow by name. Useful when you're debugging one broken screen and don't want to wait for the full suite.
/swift-assist:hierarchy - Dump the live accessibility hierarchy for the current screen. Use this when a test can't find an element or when you want to verify that identifiers are being applied correctly.
/swift-assist:coverage - Coverage dashboard: how many screens have flows, how many elements have identifiers, and what to tackle next.
Taking It to CI
Everything above runs locally. For pull request integration, Grantiva's cloud product picks up where the local workflow leaves off.
Run /swift-assist:setup-ci to generate the GitHub Actions workflow file automatically. Or add it manually:
- name: Visual Regression
run: grantiva ci run
env:
GRANTIVA_API_KEY: ${{ secrets.GRANTIVA_API_KEY }}
This runs every flow, captures screenshots, compares against baselines, and uploads the results to your Grantiva dashboard. The results appear as a GitHub Check on your pull request with per-screen pass/fail and visual diffs. Your team reviews visual changes the same way they review code changes.
The flows and screen definitions you generated locally with swift-assist are the same ones CI runs. No separate test suite to maintain.
What's Next
State management is the biggest opportunity for v2. Right now, apps that need authenticated state use mock services with a UI_TESTING build flag. That works well for apps that already have the closure-based service pattern, but we want to support more strategies: launch arguments for toggling state, test account provisioning, and seed data injection so you don't need to modify your app architecture.
But the core workflow is solid today. I ran it against the Landmarks app and went from 0 accessibility identifiers and 0 test coverage to 37 identifiers, 8 screen definitions, 6 flow files, and 25 passing test steps with VRT baselines, in a single session.
Install it:
brew install grantiva/tap/grantiva
Then install the skill:
npx skills add grantiva/swift-assist
And start with /swift-assist:init.
If you're interested in the intersection of Swift and AI, come join us at r/SwiftAndAI.