How to Build UI Tests in SwiftUI
Tag your SwiftUI views with .accessibilityIdentifier(), then use
XCUIApplication to launch the app and XCUIElement queries to
interact with and assert on those views inside an XCTestCase subclass.
// In your SwiftUI view:
Button("Add Item") { addItem() }
.accessibilityIdentifier("addItemButton")
// In your UI test:
func testAddItemButtonExists() {
let app = XCUIApplication()
app.launch()
let button = app.buttons["addItemButton"]
XCTAssertTrue(button.exists)
button.tap()
XCTAssertTrue(app.staticTexts["itemAdded"].exists)
}
Full implementation
The strategy is straightforward: decorate each interactive element in your SwiftUI views with
.accessibilityIdentifier(), then write an XCTestCase subclass in
your UI test target that launches the app via XCUIApplication and exercises real
user flows. Using setUp() and tearDown() keeps each test isolated,
and XCUIApplication.launchArguments lets you inject a test-mode flag so your app
can load deterministic data instead of real network or persistence state.
// ── ContentView.swift ──────────────────────────────────────────
import SwiftUI
struct Item: Identifiable {
let id = UUID()
var title: String
}
@Observable
final class ItemStore {
var items: [Item] = []
func add(_ title: String) {
items.append(Item(title: title))
}
}
struct ContentView: View {
@State private var store = ItemStore()
@State private var showingAdd = false
var body: some View {
NavigationStack {
List(store.items) { item in
Text(item.title)
.accessibilityIdentifier("item-\(item.title)")
}
.navigationTitle("Items")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAdd = true
} label: {
Image(systemName: "plus")
}
.accessibilityIdentifier("addItemButton")
.accessibilityLabel("Add item")
}
}
.sheet(isPresented: $showingAdd) {
AddItemView { title in
store.add(title)
showingAdd = false
}
}
}
.onAppear {
// Seed deterministic data in UI-test mode
if ProcessInfo.processInfo.arguments.contains("UI_TESTING") {
store.add("Sample A")
store.add("Sample B")
}
}
}
}
struct AddItemView: View {
var onAdd: (String) -> Void
@State private var title = ""
var body: some View {
NavigationStack {
Form {
TextField("Item name", text: $title)
.accessibilityIdentifier("itemNameField")
}
.navigationTitle("New Item")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Add") { onAdd(title) }
.disabled(title.isEmpty)
.accessibilityIdentifier("confirmAddButton")
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onAdd("") }
.accessibilityIdentifier("cancelAddButton")
}
}
}
}
}
#Preview {
ContentView()
}
// ── ItemUITests.swift (UI Testing Bundle target) ───────────────
import XCTest
final class ItemUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["UI_TESTING"]
app.launch()
}
override func tearDown() {
app = nil
super.tearDown()
}
func testInitialItemsAppear() {
XCTAssertTrue(app.staticTexts["item-Sample A"].waitForExistence(timeout: 3))
XCTAssertTrue(app.staticTexts["item-Sample B"].exists)
}
func testAddNewItem() {
app.buttons["addItemButton"].tap()
let field = app.textFields["itemNameField"]
XCTAssertTrue(field.waitForExistence(timeout: 3))
field.tap()
field.typeText("Sample C")
app.buttons["confirmAddButton"].tap()
XCTAssertTrue(app.staticTexts["item-Sample C"].waitForExistence(timeout: 3))
}
func testCancelDoesNotAddItem() {
app.buttons["addItemButton"].tap()
XCTAssertTrue(app.buttons["cancelAddButton"].waitForExistence(timeout: 3))
app.buttons["cancelAddButton"].tap()
XCTAssertFalse(app.staticTexts["item-Sample C"].exists)
}
}
How it works
-
.accessibilityIdentifier()on SwiftUI views — Every interactive or assertable view gets a stable string label (e.g.,"addItemButton"). XCUITest locates elements by this identifier viaapp.buttons["addItemButton"], which is resilient to visual layout changes. -
app.launchArguments = ["UI_TESTING"]insetUp()— This flag is passed to the host process before launch. The app checksProcessInfo.processInfo.arguments.contains("UI_TESTING")inonAppearand seeds predictable data, eliminating network or SwiftData dependencies from UI test runs. -
waitForExistence(timeout:)instead of bareexists— SwiftUI animations and sheet presentations are asynchronous.waitForExistence(timeout: 3)polls until the element appears or the timeout elapses, preventing flaky race conditions. -
continueAfterFailure = false— Stops the test immediately on the first assertion failure, preventing misleading cascading failures when an earlier step already went wrong. -
setUp()/tearDown()isolation — A freshXCUIApplicationinstance is created and launched for every test method, then nilled out, so no shared mutable state leaks between tests.
Variants
Injecting environment via launch environment
// In setUp():
app.launchEnvironment = [
"MOCK_BASE_URL": "http://localhost:8080",
"DISABLE_ANIMATIONS": "1"
]
app.launch()
// In your SwiftUI app entry point:
struct MyApp: App {
init() {
if ProcessInfo.processInfo.environment["DISABLE_ANIMATIONS"] == "1" {
UIView.setAnimationsEnabled(false)
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}
// Disabling animations eliminates timing-sensitive flakiness
// without altering production behaviour.
Taking screenshots on failure
Attach a screenshot automatically when a test fails by overriding tearDown():
override func tearDown() {
// Capture screenshot on failure
if testRun?.failureCount ?? 0 > 0 {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Failure-\(name)"
attachment.lifetime = .keepAlways
add(attachment)
}
app = nil
super.tearDown()
}
Common pitfalls
-
Forgetting
waitForExistenceafter navigation or sheets. Bare.existschecks return immediately and will fail if the view hasn't rendered yet. Always usewaitForExistence(timeout:)after any navigation, sheet presentation, or async state change. -
Querying by display text instead of identifiers.
Hardcoding
app.staticTexts["Add Item"]breaks the moment you localise the app. Prefer.accessibilityIdentifier()on the element and query by that stable key. - Running UI tests on a physical device without a signed bundle. XCUITest requires the test runner to inject into the host app, which demands proper provisioning. On CI, prefer simulators unless device-specific hardware (camera, NFC) must be tested. Also be aware UI tests are slow — keep them focused on critical happy paths and reserve edge cases for unit tests.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement ui tests in SwiftUI for iOS 17+. Use XCUITest. Make it accessible (VoiceOver labels). Add a #Preview with realistic sample data.
In Soarias's Build phase, drop this prompt into the active session after your SwiftUI views are scaffolded — Claude Code will add .accessibilityIdentifier() modifiers and generate the entire UI test target in one pass, ready to run in Xcode without manual wiring.
Related
FAQ
Does this work on iOS 16?
The XCUITest APIs used here — XCUIApplication,
waitForExistence, launchArguments — have been available since
iOS 9 and work on iOS 16. However, the @Observable macro used in
ItemStore requires iOS 17+. Swap it for
@ObservableObject / @Published if you need to support iOS 16, and the
UI tests themselves remain unchanged.
How do I test a SwiftData-backed list without real persistent data?
Pass a custom launch argument (e.g., "USE_IN_MEMORY_STORE") and configure
your ModelContainer with ModelConfiguration(isStoredInMemoryOnly: true)
when that argument is present. This gives you an isolated, empty store for every test run,
with no leftover data from previous launches.
What is the UIKit equivalent?
UIKit apps use the exact same XCUITest framework — there is no separate API.
The main difference is that UIKit views don't have .accessibilityIdentifier()
as a modifier; instead you set view.accessibilityIdentifier = "myButton" in
code or via the Accessibility Identity panel in Interface Builder. Everything else —
XCUIApplication, element queries, waitForExistence — is
identical.
Last reviewed: 2026-05-11 by the Soarias team.