All articles
iOS16 min read

UI Testing in SwiftUI (2025 Guide): Write End-to-End Tests for Reliable iOS Apps

UI testing got you sweating bullets? 🫠 In this 2025 guide, you’ll learn how to write reliable end-to-end tests for your SwiftUI apps —…

K
Karan Pal
Author
Image generated by AI
Image generated by AI

🧪 Introduction

Let’s be honest — SwiftUI makes building UIs feel magical. But when it comes to testing them? That’s where most developers go from 😎 to 😵‍💫 real quick.

UI testing is often the last thing added (and the first thing ignored) in many SwiftUI projects. And who can blame you? It’s notoriously flaky, hard to set up, and tends to break the moment someone nudges a button two pixels to the left. But here’s the deal — reliable UI testing is one of the strongest tools you have for shipping confident, bug-free apps, especially in 2025 where apps are built fast and updated even faster.

In this guide, we’ll break down everything you need to know about UI Testing in SwiftUI — from setup to writing end-to-end flows, dealing with modals, navigation, flaky elements, and even custom extensions to make tests easier to write and maintain.

Whether you’re just getting started or have been hacking around XCUIApplication() for a while, this guide is structured to be your go-to resource. You’ll learn how to:

Oh, and if you haven’t already mastered the fundamentals, you might want to check out:

Ready to stop dreading UI tests and start loving them? Let’s dive in. 🚀

⚙️ 2. Setting Up UI Testing in Xcode

If you’re new to UI Testing in Swift, the setup might seem a bit mysterious — like Xcode is hiding the good stuff behind secret checkboxes. But don’t worry, it’s actually straightforward once you know where to click (and what to avoid).

🧱 Create a UI Test Target (Just Once!)

To get started:

  1. Open your Xcode project.
  2. Go to File → New → Target…
  3. Choose “UI Testing Bundle”
  4. Name it something like MyAppUITests and click Finish.

you now have a separate target specifically for UI tests.

You’ll see a new group in your project navigator (usually named YourAppUITests), and Xcode even gives you a starter file to build on.

🧪 Anatomy of a UI Test File

Here’s what that default file might look like:

final class MyFirstSwiftUIAppUITests: XCTestCase {

    override func setUpWithError() throws {
        continueAfterFailure = false
    }

    override func tearDownWithError() throws {
        // Cleanup code (rarely needed)
    }

    @MainActor
    func testExample() throws {
        let app = XCUIApplication()
        app.launch()
        // Use XCTAssert and related functions to verify your tests produce correct results
    }

    @MainActor
    func testLaunchPerformance() throws {
        if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
            measure(metrics: [XCTApplicationLaunchMetric()]) {
                XCUIApplication().launch()
            }
        }
    }
}

Let’s unpack this:

💡 Pro Tip: Add Launch Arguments

Want to skip onboarding, load mock data, or disable animations just for tests? You can pass launch arguments and environment variables:

@MainActor
func testExample() throws {
    // UI tests must launch the application that they test.
    
    let app = XCUIApplication()
    app.launchArguments = ["-UITestMode", "-SkipOnboarding"]
    app.launchEnvironment = ["ENV": "TEST"]


    XCTAssertTrue(app.staticTexts["UITestModeBanner"].exists)
    
}

Then in your view code:

struct ContentView: View {
    private let isUITestMode = CommandLine.arguments.contains("-UITestMode")

    var body: some View {
        VStack {
            Text("Welcome")

            if isUITestMode {
                Text("🧪 UI Test Mode Detected")
                    .accessibilityIdentifier("UITestModeBanner")
            }
        }
    }
}

Run the test, you should see a Text with value “🧪 UI Test Mode Detected”.

To run the test you can click on the hollow button in the gutter area before the function declaration.

This logic is safe, test-friendly, and can be used to:

🎥 You Can Record… But Don’t Trust It Blindly

Xcode also has a recording tool that watches your interactions and generates test code as you use your app.

It’s a great way to learn how to write tests, but don’t rely on the recorded code as-is. It’s often fragile and too literal. Use it as a starting point — not a finished product.

Your UI Test target is now officially set up and ready for action. Next, let’s explore the toolbox you’ll use to write tests that can tap, assert, and validate everything from buttons to full navigation stacks.

🛠 3. The XCUI Toolbox: Taps, Types, Assertions & Beyond

Once you’ve got your SwiftUI app launching in test mode, it’s time to actually interact with it. This is where XCUIApplication, XCTAssert, and the powerful XCUIElement APIs come in.

If you’re imagining writing UI tests is like tapping around the app as a robot with a checklist — you’re not wrong. Let’s break it down.

🎯 Accessing Elements

Everything starts with XCUIApplication():

let app = XCUIApplication()
app.launch()

Once launched, you can grab UI elements with methods like:

app.buttons["Login"]
app.textFields["Email"]
app.staticTexts["Welcome"]
app.images["AppLogo"]

➡️ These string values must match the accessibility identifiers you set in your SwiftUI code.

🧼 Use .accessibilityIdentifier(_:) in SwiftUI

TextField("Email", text: $email)
    .accessibilityIdentifier("emailTextField")

Button("Login") {
    // action
}
.accessibilityIdentifier("loginButton")

This makes elements uniquely identifiable — don’t rely on .label or .placeholder unless you love flaky tests.

👆 Performing UI Actions

Here’s how you simulate real-world user interactions:

app.textFields["emailTextField"].tap()
app.textFields["emailTextField"].typeText("user@example.com")

app.secureTextFields["passwordTextField"].tap()
app.secureTextFields["passwordTextField"].typeText("password123")

app.buttons["loginButton"].tap()

👉 Note: Use .firstMatch if you get multiple elements back.

app.textFields["emailTextField"].firstMatch.tap()

✅ Making Assertions

This is how your test knows if things actually worked.

XCTAssertTrue(app.staticTexts["WelcomeMessage"].exists)
XCTAssertEqual(app.textFields["emailTextField"].value as? String, "user@example.com")

Or, wait for a login success screen to appear:

let homeScreen = app.staticTexts["Success"]
XCTAssertTrue(successScreen.waitForExistence(timeout: 5))

Use waitForExistence(timeout:) to avoid flaky failures.

🧠 Pro Tip: Querying by Hierarchy

You can also go surgical with queries:

app.scrollViews.otherElements.buttons["Sign Out"]
app.navigationBars.buttons.element(boundBy: 0)

But keep it simple when possible. The flatter and more stable your view structure, the better your tests will hold up over time.

🧪 Putting It All Together

Here’s a mini login test in action:

@MainActor
func testLoginFlow() throws {
    let app = XCUIApplication()
    app.launchArguments = ["-UITestMode"]
    app.launch()

    let email = app.textFields["emailTextField"]
    XCTAssertTrue(email.waitForExistence(timeout: 3))
    email.tap()
    email.typeText("me@swiftpal.dev")

    let password = app.secureTextFields["passwordTextField"]
    password.tap()
    password.typeText("testpassword")

    app.buttons["loginButton"].tap()

    let homeScreen = app.staticTexts["Home"]
    XCTAssertTrue(homeScreen.waitForExistence(timeout: 5))
}

🧩 4. Making Your SwiftUI Views Testable

Writing tests isn’t just about poking buttons and hoping things don’t crash — it’s about designing your UI to be predictable, observable, and controllable. SwiftUI gives you all the tools — you just need to know where to look (and tap).

Let’s turn your views into testable, scalable machines 🧪⚙️

🎯 Why “Testability” Matters in SwiftUI

SwiftUI apps tend to couple UI state with logic, especially when everything is inside .sheet() modifiers or .task blocks. That’s why testability = visibility + control.

In short:

🧠 1. Isolate State with ViewModels

When logic lives in your view, testing becomes painful. Move it out:

class LoginViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""

    func login() {
        // Perform login
    }
}

Now your SwiftUI view becomes just a UI layer:

struct LoginView: View {
    @ObservedObject var viewModel: LoginViewModel

    var body: some View {
        VStack {
            TextField("Email", text: $viewModel.email)
                .accessibilityIdentifier("emailTextField")

            SecureField("Password", text: $viewModel.password)
                .accessibilityIdentifier("passwordTextField")

            Button("Login") {
                viewModel.login()
            }
            .accessibilityIdentifier("loginButton")
        }
    }
}

🧪 With this setup, you can:

🧩 2. Handle Navigation and Sheets Smartly

Testing .sheet() and .navigationDestination() is hard when it’s all tied up inside view modifiers.

Make them testable by using state flags:

@State private var isShowingSheet = false

Button("Show Details") {
    isShowingSheet = true
}
.sheet(isPresented: $isShowingSheet) {
    Text("Details")
        .accessibilityIdentifier("detailsSheet")
}
.accessibilityIdentifier("showDetailsButton")

🧪 In tests:

app.buttons["showDetailsButton"].tap()
XCTAssertTrue(app.staticTexts["detailsSheet"].exists)

💡 Use Feature Flags or Launch Arguments to Toggle Logic

We already used -UITestMode to enter test environments. You can pass other flags like:

In your SwiftUI code:

let isUsingMockData = CommandLine.arguments.contains("-UseMockData")

Or set it to UserDefaults.standard.bool(forKey:) in @main and read throughout the app.

🔧 Use Dependency Injection (DI) for Clean Testing

Inject services, data providers, or state containers. Avoid singletons in testable views.

📌 Learn how: Dependency Injection in Swift — A Beginner to Advanced Guide

**Dependency Injection in Swift: A Beginner-to-Advanced Guide** _🧪 Feeling tangled in singletons and tightly-coupled code? Learn how Dependency Injection in Swift can help you write…_medium.comhttps://medium.com/swift-pal/dependency-injection-in-swift-a-beginner-to-advanced-guide-b85378c6f8d2

🚨 Don’t Forget the Identifiers!

This one’s easy to miss:

Text("Welcome")
    .accessibilityIdentifier("welcomeMessage")

Without identifiers, even the best test won’t find your elements. Give every actionable or asserted element a stable accessibilityIdentifier.

⚠️ 5. Handling Alerts, Sheets & State-Driven UI in Tests

While SwiftUI makes it easy to show alerts, modals, and other state-driven UI — writing stable tests for them takes a bit more finesse. This section shows you how to deal with ephemeral elements that pop in and out of your app when the user (or test) triggers them.

📣 Testing Alerts

SwiftUI’s .alert() is commonly used for confirmations and errors. But unlike views on the screen, alerts exist in a temporary layer — so your test needs to wait for them.

🧪 Example:

.alert("Confirm Delete", isPresented: $showAlert) {
    Button("Delete", role: .destructive) { deleteItem() }
}

In your test:

let alert = app.alerts["Confirm Delete"]
XCTAssertTrue(alert.waitForExistence(timeout: 2))
alert.buttons["Delete"].tap()

🧠 Pro Tip: Use a short timeout to fail fast if the alert doesn’t appear.

📱 Testing Sheets and Modals

SwiftUI modals, like .sheet() or .fullScreenCover(), are presented asynchronously — and often rely on state variables to toggle their visibility. That means your test needs to first trigger the state and then verify the result.

🧪 Example:

Button("Show Detail") {
    showSheet = true
}
.sheet(isPresented: $showSheet) {
    Text("Detail View")
        .accessibilityIdentifier("detailSheet")
}
.accessibilityIdentifier("showDetailButton")

In your test:

app.buttons["showDetailButton"].tap()
XCTAssertTrue(app.staticTexts["detailSheet"].waitForExistence(timeout: 3))

✅ Always give modal content an .accessibilityIdentifier() — otherwise your test might miss it entirely.

🎛 Testing State-Driven Conditional UI

SwiftUI often conditionally renders views using flags or logic:

if viewModel.isLoggedIn {
    Text("Welcome back!")
        .accessibilityIdentifier("welcomeText")
}

Since this view appears only after a state change (like a successful login), your test should:

  1. Trigger the state (e.g., tap the login button)
  2. Wait for the conditional element
  3. Assert its presence
let welcome = app.staticTexts["welcomeText"]
XCTAssertTrue(welcome.waitForExistence(timeout: 5))

🧠 Important: Direct .exists assertions without a wait may pass locally but fail in CI or slower devices.

👋 Dismissing Sheets or Alerts

Want to test closing things too? Just tap the dismiss element:

app.buttons["dismissButton"].tap()
XCTAssertFalse(app.staticTexts["detailSheet"].exists)

If it was presented with .sheet(isPresented:), that exists check should go false after tapping.

🧭 6. Testing Navigation: Stack, Tabs & Custom Flows

SwiftUI made navigation super declarative… and your UI tests just a little more interesting 😅. Whether you’re using NavigationLink, .sheet(), .tabView, or custom flows — here’s how to ensure your app goes where it’s supposed to.

🚪 Stack-Based Navigation with NavigationLink

Let’s start with the classic: tapping something to push a new screen.

NavigationLink("Go to Details", destination: DetailView())
    .accessibilityIdentifier("detailsLink")

🧪 In tests:

app.buttons["detailsLink"].tap()
XCTAssertTrue(app.staticTexts["detailViewTitle"].waitForExistence(timeout: 3))

✅ Add accessibilityIdentifier to the label and to something unique in the destination (like a heading).

🧭 Testing NavigationStack and Back Navigation

If you’ve embraced NavigationStack, your push/pop transitions are easier to manage, but you still need to test them the same way.

For example, to test going back:

app.navigationBars.buttons.element(boundBy: 0).tap()

🧠 You can also use app.navigationBars["YourTitle"].buttons["Back"] if your title is static — though this can get flaky with long titles or localizations.

🗂 Testing Tabs in TabView

When you have a TabView, switching tabs in UI tests is simple — if you label your tabs right:

TabView {
    HomeView()
        .tabItem {
            Label("Home", systemImage: "house")
        }
        .accessibilityIdentifier("tab_home")

    ProfileView()
        .tabItem {
            Label("Profile", systemImage: "person")
        }
        .accessibilityIdentifier("tab_profile")
}

🧪 In your test:

app.buttons["tab_profile"].tap()
XCTAssertTrue(app.staticTexts["profileHeading"].waitForExistence(timeout: 2))

💡 Tabs are just buttons in the hierarchy, so .buttons["tab_profile"] works if you assigned an identifier.

🧭 Test Deep Links & Flow-Dependent Navigation

For more complex flows — say, tapping a cell that opens a detail, which opens another screen — you’ll need to assert each transition step-by-step.

app.cells["productCell_42"].tap()
XCTAssertTrue(app.staticTexts["productDetails"].waitForExistence(timeout: 2))

app.buttons["buyNowButton"].tap()
XCTAssertTrue(app.staticTexts["checkoutTitle"].waitForExistence(timeout: 2))

Add accessibilityIdentifiers liberally in each screen. You can also simulate deep-linking via custom launch arguments or environment variables if your app supports them.

🧠 Bonus Tip: Always Test the “Back” Path Too

It’s easy to test pushing forward — but flaky tests often come from untested back navigation. Make sure to also tap the back button and assert that the previous screen is visible again.

🧪 7. Advanced UI Testing Tips: CI, Flakiness & Real-World Tactics

UI tests look all shiny in the demo. But once they hit CI? Oh boy — flakiness, race conditions, and random failures come out to party. This section arms you with real-world battle strategies so your tests stay stable, fast, and don’t make your teammates cry.

⚡ 1. Don’t Rely on exists — Use waitForExistence(timeout:)

Why?

✅ This will save 80% of your flake-related headaches:

XCTAssertTrue(app.buttons["loginButton"].waitForExistence(timeout: 3))

🧘 2. Disable Animations During Tests

Animations make things pretty — and UI tests flaky.

🛑 Don’t test with them on. Disable like this:

Add this launch argument:

app.launchArguments += ["-UITestsDisableAnimations"]

Then in app code:

if CommandLine.arguments.contains("-UITestsDisableAnimations") {
    UIView.setAnimationsEnabled(false)
}

🎯 Works for SwiftUI too — it uses UIView under the hood for many transitions.

🏃 3. Run UI Tests in Parallel with Care

If you run tests in parallel on CI:

📌 Prefer running tests in serial mode unless absolutely necessary.

📸 4. Use Screenshots for Debugging

Xcode lets you capture a screenshot if a test fails:

if !element.exists {
    let screenshot = XCUIScreen.main.screenshot()
    let attachment = XCTAttachment(screenshot: screenshot)
    attachment.lifetime = .keepAlways
    add(attachment)
}

📂 These screenshots show up in your Xcode test report and are gold when debugging CI failures.

🧼 5. Always Reset State Between Tests

Don’t let one test leak into the next:

let app = XCUIApplication()
app.launchArguments = ["-UITestMode", "-ResetApp"]
app.launch()

Then in your SwiftUI app:

if CommandLine.arguments.contains("-ResetApp") {
    // Clear defaults, remove files, reset state
}

🧠 6. Use Mocks and Launch Arguments to Inject Test Data

Real networking + UI = 🧨. Keep UI tests stable by injecting mocks.

Inject them via:

📌 You can go deeper with this here: 👉 Dependency Injection in Swift — A Beginner to Advanced Guide

**Dependency Injection in Swift: A Beginner-to-Advanced Guide** _🧪 Feeling tangled in singletons and tightly-coupled code? Learn how Dependency Injection in Swift can help you write…_medium.comhttps://medium.com/swift-pal/dependency-injection-in-swift-a-beginner-to-advanced-guide-b85378c6f8d2

💥 7. Handle Flakiness in CI

Some tips from the trenches:

🧪 A flaky test is worse than no test.

😵 Common Gotchas in SwiftUI UI Testing (And How to Dodge Them)

🔁 Using **.exists** without waiting app.buttons["Login"].exists might return false — not because the button isn’t there, but because it isn’t ready yet. 🛡️ Use .waitForExistence(timeout:) to give the UI time to render.

🧪 Relying on **print()** for debugging You’ll expect logs, and see… nothing. UI test environments often swallow console output. 🛡️ Show state changes via UserDefaults, conditional UI, or test-only views instead.

🧼 Calling **scrollToElement()** (that doesn’t exist) It sounds useful, but XCTest never gave us this one. 🛡️ Build your own scroll loop: while !element.exists { app.swipeUp() }.

🚫 Trying **.isUITesting** in SwiftUI There’s no magic environment key like .isUITesting. 🛡️ Pass a custom launch argument like -UITestMode, then check it with CommandLine.arguments.contains("-UITestMode").

🆔 Forgetting **accessibilityIdentifier**s If your views aren’t discoverable, your tests won’t work — period. 🛡️ Tag views with .accessibilityIdentifier("someID") and avoid relying on labels or positions.

⚙️ Not customizing launch flow If you don’t pass any launch arguments, tests start like a new user — onboarding and all. 🛡️ Use app.launchArguments and app.launchEnvironment to control state, skip onboarding, and inject mocks.

🔴 Waiting for the red record button Yes, the Record UI Test button exists — but it’s often moody. Sometimes it just won’t show up. 🛡️ Write tests manually. It’s faster, cleaner, and works better with SwiftUI.

🧭 Where to Go From Here?

You’ve just leveled up from “tapping buttons and praying” to writing robust, maintainable UI tests for SwiftUI — the kind that survive CI, deal with real user flows, and save your bacon when a refactor breaks things 🥓💥.

But we’re not done yet.

✨ You Now Know How To:

✅ Write UI tests with XCTest that handle:

✅ Avoid wild guesses like:

📚 Want to Keep Learning?

Here are articles you’ll love next:

All of them help make your Swift apps easier to test, debug, and scale.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading