```html SwiftUI: How to Build UI Tests (iOS 17+, 2026)

How to Build UI Tests in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: XCUITest Updated: May 11, 2026
TL;DR

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

  1. .accessibilityIdentifier() on SwiftUI views — Every interactive or assertable view gets a stable string label (e.g., "addItemButton"). XCUITest locates elements by this identifier via app.buttons["addItemButton"], which is resilient to visual layout changes.
  2. app.launchArguments = ["UI_TESTING"] in setUp() — This flag is passed to the host process before launch. The app checks ProcessInfo.processInfo.arguments.contains("UI_TESTING") in onAppear and seeds predictable data, eliminating network or SwiftData dependencies from UI test runs.
  3. waitForExistence(timeout:) instead of bare exists — SwiftUI animations and sheet presentations are asynchronous. waitForExistence(timeout: 3) polls until the element appears or the timeout elapses, preventing flaky race conditions.
  4. continueAfterFailure = false — Stops the test immediately on the first assertion failure, preventing misleading cascading failures when an earlier step already went wrong.
  5. setUp() / tearDown() isolation — A fresh XCUIApplication instance 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

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.

```