If you’ve ever shipped a bug where a button did nothing, a sheet didn’t show, or the wrong item appeared on screen—this post is for you.
Most iOS app bugs happen in the UI layer. Unit tests won’t catch them, and manual testing is slow and unreliable. That’s where UI tests come in.
In this post, you’ll learn how to:
- Write your first UI test for a SwiftUI app using XCUITest
- Use accessibility identifiers to reliably find and interact with views
- Assert that your app behaves correctly—just like a real user would
You’ll use a simple SwiftUI app as an example, but everything here also applies to UIKit apps. XCUITest works across both.
These are real iOS tests—they launch the full app in a simulator and simulate actual user interaction.
Let’s dive in.
⬇️ Project files: https://github.com/gahntpo/iOS-testing
Why UI Tests Matter
Even if you have 100% unit test coverage, your app can still break:
- Buttons wired to the wrong action
- Views not showing because of missing state
- Sheets or alerts not appearing
- Broken navigation
UI tests catch these problems because they run the real app, from launch to user interaction.
They’re especially powerful for:
- Testing happy paths from the user’s point of view
- Preventing regressions in tap/gesture flow
- Validating dynamic UI behaviour (like showing/hiding buttons or disabling inputs)
You don’t need to test every pixel—just the logic that connects user interaction to state.
Setting Up UI Testing in Xcode
You can start with a new Xcode project, or use your existing one.
Option 1: Create a New Project with Tests Enabled
When creating a new iOS project, for the Testing System sections choose either Swift Testing or XCTest. Note that UI tests only work with XCTest.
This creates:
- A unit test target
- A UI test target

Option 2: Add a UI Test Target to an Existing Project
If you already have a working SwiftUI app (like the ItemApp
from earlier), you can add a UI test target manually:
- In Xcode, go to File > New > Target
- Choose iOS > UI Testing Bundle
- Name it something like
ItemAppUITests
- Make sure it targets the right app
Now you’ll see a new folder with a file like this:
final class ItemAppUITests: XCTestCase {
func testExample() {
// your test will go here
}
}
Great—you’re ready to start testing.

SwiftUI App Under Test
This is a demo app, where you can see a list of items, add and delete them.

In a previous post, I used the same app example to write unit tests with swift testing. You can read about it here.
Adding Accessibility Identifiers
To make views accessible to your UI tests, you need to add accessibility identifiers to them. These are string tags that XCUITest uses to find buttons, text fields, and other UI elements.
In the example app, you can see SwiftUI views like so:
Button("Remove Last") {
viewModel.deleteLastItem()
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.deleteDisabled)
This works, but XCUITest can’t find this button unless you give it a name. So we add:
.accessibilityIdentifier("button.delete")
Repeat this for all important UI elements.
Recommended Naming: Unique and Clear
Use a consistent naming scheme:
<screen>.<type>.<purpose>
Why?
- It prevents conflicts between views (e.g. two buttons named
"add"
) - It makes it clear where the element lives
- It scales well as your app grows
Examples
Identifier | Meaning |
---|---|
"AddNewItemScreen.button.confirm” | Add button on the item creation screen |
"AddNewItemScreen.textfield.newItem" | Text input field for new item name |
“ | Add button on item list screen |
“ | Delete last item button |
"ItemListScreen.view.itemList" | Main scroll view showing all items |
"ItemListScreen.item.<itemName>" | A visible item in the list (dynamic) |
Pro Tip: Use a Central enum to Store Them
Hardcoding strings across your app and test files is a recipe for broken tests.
Instead, define identifiers once in a shared file:
enum UIIdentifiers {
enum AddNewItemScreen {
static let addButton = "AddNewItemScreen.button.add"
static let itemNameTextField = "AddNewItemScreen.textfield.newItem"
static let confirmButton = "AddNewItemScreen.button.confirm"
}
enum ItemListScreen {
static let itemListView = "ItemListScreen.view.itemList"
static let deleteButton = "ItemListScreen.button.delete"
static let addButton = "ItemListScreen.button.add"
static func item(_ name: String) -> String {
"ItemListScreen.item.(name)"
}
}
}
This makes your UI tests:
- Easier to write
- Easier to refactor
- Less likely to break from typo bugs
Make sure to add this file to both your main app and ui testing target, so you can access it for you tests.

Next, I’ll show you how to update your actual SwiftUI views to use these identifiers—and then we’ll write your first UI test.
Updating the SwiftUI Views with Accessibility Identifiers
Add identifiers to ContentView for the scrollview items, delete and add buttons:
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel: ItemViewModel
@State private var showingAddSheet = false
var body: some View {
VStack {
ScrollView {
ForEach(viewModel.items) { item in
Text(item.name)
.padding()
.frame(maxWidth: .infinity)
.background(Capsule().fill(Color.yellow))
.accessibilityIdentifier(UIIdentifiers.ItemListScreen.item(item.name))
}
}
.accessibilityIdentifier(UIIdentifiers.ItemListScreen.itemListView)
Divider()
HStack {
Button("Remove Last") {
viewModel.deleteLastItem()
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.deleteDisabled)
.accessibilityIdentifier(UIIdentifiers.ItemListScreen.deleteButton)
Button {
showingAddSheet = true
} label: {
Label("Add Item", systemImage: "plus")
}
.buttonStyle(.bordered)
.accessibilityIdentifier(UIIdentifiers.AddNewItemScreen.addButton)
.sheet(isPresented: $showingAddSheet) {
NewItemView(viewModel: viewModel)
}
}
}
.padding()
}
}
For NewItemView add identifiers to the textfield and button like so:
struct NewItemView: View {
@ObservedObject var viewModel: ItemViewModel
@State private var newItemName = ""
@Environment(.dismiss) var dismiss
var body: some View {
VStack(spacing: 20) {
TextField("Item name", text: $newItemName)
.padding()
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier(UIIdentifiers.AddNewItemScreen.itemNameTextField)
Button("Add Item") {
if !newItemName.isEmpty {
viewModel.addItem(name: newItemName)
newItemName = ""
dismiss()
}
}
.buttonStyle(.borderedProminent)
.disabled(newItemName.isEmpty)
.accessibilityIdentifier(UIIdentifiers.AddNewItemScreen.confirmButton)
}
.padding()
}
}
Now your app is testable: every important UI element is uniquely and clearly tagged. You’re ready to write your first UI test.
Writing Your First SwiftUI UI Test with XCUITest
We want to check:
- That the screen loads correctly
- That we can locate the container (e.g., scroll view or list) of items
- That the expected number of items is visible
This test checks the happy path—the UI is working as expected and showing 4 predefined items.
I will start by writing the test function where we test the shown items like so:
func test_items_shown() {
let app = XCUIApplication()
app.launch()
}
We create a new XCUIApplication
and launch it. Every UI test starts from a clean app launch—no shared state, no leftovers from previous tests.
Run the Test
You can now run it:
- Click the diamond ▶️ next to the test method
- In the Test Navigator on the left, click the diamond for the corresponding test
- Or press
⌘ + U
to run all tests - Use
⌘ + ⇧ + U
to build all tests without running

Locating the UI Elements
You can ask the app instance for UI elements. For example, I can access views by their type and accessibility id. The following finds the scrollview by id:
func test_items_shown() {
let app = XCUIApplication()
app.launch()
let collection = app.scrollViews["items.collection"]
}
But if you want to change your implementation in the future and change from a scroll view to e.g. a list, the test will fail. This would mean the test is brittle (breaks with future refactoring). A better more flexible approach is to look for elements that match the identifier like so:
let id = UIIdentifiers.ItemListScreen.itemListView
let collection = app.descendants(matching: .any)[id]
Using .descendants(matching: .any)[id]
to find any type of view by identifier. It’s more stable against refactors.
Another problem with XCUITest is that they can be flaky that means they randomly fail. One reason may be that the UI is not refreshing fast enough and the UI elemente have not appeared yet when you check the assertions. To solve this and prevent flaky tests you can use .waitForExistence(timeout:)
. It makes the test more stable—it gives the UI a second to appear before failing.
func test_items_shown() {
let app = XCUIApplication()
app.launch()
let id = UIIdentifiers.ItemListScreen.itemListView
let collection = app.descendants(matching: .any)[id]
XCTAssertTrue(collection.waitForExistence(timeout: 1),
"The items should be visible")
}
Next I want to find the rows in the scrollview. I can use another search and define a predicate:
let itemId = UIIdentifiers.ItemListScreen.item("")
let predicate = NSPredicate(format: "identifier CONTAINS '\(itemId)'")
let items = collection.descendants(matching: .any).matching(predicate)
Since each item has an identifier like "ItemListScreen.item.first"
or "ItemListScreen.item.second"
, we use a predicate to match anything that starts with "ItemListScreen.item."
. This way, the test doesn’t depend on the exact names, just the count.
Thus the full test looks like:
import XCTest
final class ItemAppUITests: XCTestCase {
func test_items_shown() {
let app = XCUIApplication()
app.launch()
let collection = app.descendants(matching: .any)[UIIdentifiers.ItemListScreen.itemListView]
XCTAssertTrue(collection.waitForExistence(timeout: 1),
"The items should be visible")
let itemId = UIIdentifiers.ItemListScreen.item("")
let predicate = NSPredicate(format: "identifier CONTAINS '(itemId)'")
let items = collection.descendants(matching: .any).matching(predicate)
XCTAssertTrue(items.count > 0,
"There should be at least one item on the screen")
}
}
This is a good assertion because:
- It’s flexible to future refactoring: we don’t expect exactly x items which might change in the future
- It’s visible: if it fails, it tells you why
- It avoids testing layout or view types—just the behaviour
Attach a Screenshot (Bonus)
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways
add(attachment)
Attaching a screenshot is helpful for debugging later—especially in CI or when a test fails unexpectedly.
Defaults to ~XCTAttachment.Lifetime.deleteOnSuccess~, indicating that the attachment should be discarded when its test passes successfully, to save on storage space. Set this property to ~XCTAttachment.Lifetime.keepAlways~ to persist an attachment even when its test passes.
To find the screenshots go to the Test Navigator
and chose the test:

You can also see how long the test runs. In the above example, the test takes 5sec.

You can view the screenshot for later debugging. You can also export it. This can be useful if you want to automatically generate screenshots for app store connect.
Testing Adding New Items
In the next test I want to verify that:
- The “Add” button opens the new item sheet
- The user can enter a name
- Tapping “Add Item” closes the sheet
- The new item appears in the list

This is a full user interaction flow test. It checks that the UI is wired up correctly and behaves as expected end-to-end.
Here’s your working test:
import XCTest
final class ItemAppUITests: XCTestCase {
func test_addItem_showsItemInList() {
// --- GIVEN ---
let app = XCUIApplication()
app.launch()
// --- WHEN ---
app.buttons[UIIdentifiers.AddNewItemScreen.addButton].tap()
let textField = app.textFields[UIIdentifiers.AddNewItemScreen.itemNameTextField]
XCTAssertTrue(textField.waitForExistence(timeout: 1))
textField.tap()
textField.typeText("New Item")
app.buttons[UIIdentifiers.AddNewItemScreen.confirmButton].tap()
// --- THEN ---
let newItem = app.staticTexts[UIIdentifiers.ItemListScreen.item("New Item")]
XCTAssertTrue(newItem.waitForExistence(timeout: 2))
}
}
What This Test Does
XCUIApplication().launch()
boots your app just like a user would- It taps the Add button (using the identifier) to open the sheet
- In the sheet we locate the
"New Item TextField"
- It enters
"New Item"
into the text field - It confirms the addition by taping the
"confirmButton"
- It asserts that the item now exists in the list
This is the most important part: verifying that the item actually appears on screen. Not just that the code ran—but that the user sees the result.
Using .waitForExistence(timeout:) here gives the UI a moment to update and avoids false negatives on slower devices or CI.
🛑 If the Test Fails
If something breaks—wrong identifier, sheet not showing—you’ll see exactly where and why.
For example:
- Forgot to add
.accessibilityIdentifier
? - Sheet not presenting correctly?
- ViewModel logic didn’t run?
That’s exactly what UI tests are for—they catch real bugs that slip past unit tests.
Test: Deleting All Items Disables the Delete Button
In this test, we’ll simulate repeatedly tapping the “Remove Last” button until no items remain. Then we’ll assert that:
- The Delete button removes items from the list one by one
- All items are eventually removed
- The “Remove Last” button becomes disabled when the list is empty
- This test verifies the entire “delete flow” and UI state update logic.

I will create a new test function like so:
func test_delete_button_removes_all_items_and_disables_button() {
// --- GIVEN ---
let app = XCUIApplication()
app.launch()
let deleteButton = app.buttons[UIIdentifiers.ItemListScreen.deleteButton]
let collection = app.descendants(matching: .any)[UIIdentifiers.ItemListScreen.itemListView]
XCTAssertTrue(collection.waitForExistence(timeout: 2),
"Items should be visible")
XCTAssertTrue(deleteButton.waitForExistence(timeout: 2),
"Delete button should exist")
}
We locate both the button and the container (scroll view/list) and make sure they’re present before continuing. Always assert the UI is ready before interacting.
Next, I will count how many items are currently visibile:
let predicate = NSPredicate(format: "identifier CONTAINS 'item.'")
let itemElements = collection.descendants(matching: .any).matching(predicate)
let initialCount = itemElements.count
XCTAssertGreaterThan(initialCount, 0, "There should be items to delete")
XCTAssertTrue(deleteButton.isEnabled, "Delete button should be enabled initially")
Instead of hardcoding the number of delete taps, we:
- Count how many items are currently in the list
- Verify that there’s at least one item
- Confirm the Delete button is enabled
💡 Why this matters: This makes the test resilient—it still works if the initial item list changes.
Tap Delete Until All Items Are Gone
// --- WHEN ---
for _ in 0..<initialCount {
deleteButton.tap()
}
We dynamically tap the Delete button for every item that exists. This simulates how a user might clear out the list manually.
Then: Verify UI Is Updated
// --- THEN ---
XCTAssertTrue(collection.waitForExistence(timeout: 2),
"Item List should be visible")
let remainingItems = collection.descendants(matching: .any).matching(predicate)
XCTAssertEqual(remainingItems.count, 0,
"All items should be removed from the screen")
XCTAssertFalse(deleteButton.isEnabled,
"Delete button should be disabled after all items are removed")
We assert that:
- No items remain in the UI
- The Delete button is now disabled, as expected
This checks that the ViewModel → View state binding works and that the UI reacts correctly.
Why This Test Works Well
- ✅ No hardcoded tap counts
- ✅ Verifies both visual state and interactivity
- ✅ Easy to maintain as the app evolves
- ✅ Clear failure messages help pinpoint what broke
This test covers not just business logic, but the end result that the user actually sees and interacts with.
Advanced: Controlling App Behavior in Tests
UI tests always launch your app from scratch—which means you can control how it launches.
For example, you can pass launch arguments or environment variables to:
- Load mock data
- Use a temporary file for test storage
- Clear
UserDefaults
or caches - Inject staging URLs or fake config
This keeps your tests clean, repeatable, and isolated.
👉 I’ll cover this in a follow-up post:
“How to Inject Data and Configuration into Your iOS App for UI Tests”
It includes examples using CommandLine.arguments
, ProcessInfo.environment
, and temp file paths.
Pros and Cons of UI Testing in iOS with XCUITest
UI tests let you automate real user flows—but they’re not magic. They work by launching the entire app from scratch in a simulator and interacting with the UI like a human would. This gives you full visibility into what users experience—but it also comes with downsides.
✅ Pros: Why UI Tests Are Worth It
Benefit | Why It Matters |
---|---|
Test the real app | Validates actual navigation, buttons, and views |
Great for full flows | Ideal for testing “add item → confirm → see item” |
High confidence | Catches bugs unit tests can’t (e.g. button not wired) |
Runs in CI | Can catch regressions on every commit |
⚠️ Cons: The Real Trade-offs
What i saw so far from the iOS developer community, the most common sentiment about XCUITest are that they are flaky and slow.
Limitation | Why It Hurts |
---|---|
Slow | Each test launches the full app from scratch |
No access to app internals | Can’t read variables or inspect state directly |
Flaky | Timing issues (e.g. animations, async loads) cause random failures |
Harder to debug | Failures often give vague errors |
Harder to isolate | Shared state must be reset between runs |
Probably the biggest downside to XCUITest is the test speed. The above 3 tests take together 27 seconds. They run one after the other in the simulator. You can imagine that this becomes quickly too slow to run frequently, if you want to use these test during Test-Driven-Develelpment TDD. The longer tests take the less likely you will be to actually run them. That is way most apps only add a few test flows, the most important long happy path.
In the following you can see the test results for both ui and unit tests:

The slowest UI test takes 12 sec vs the slowest unit test with 0.0023 sec. Because of this massive difference, most iOS devs will avoid UI tests and concentrate mostly on writing unit tests. You can have hundreds of unit tests runing <1sec that you run frequently during your development process withouth blocking you.
This is unfortunate since most bugs in iOS development are UI related. Avoiding UI testing makes this worse. There have been 3rd party libraries that try to give you fast unit tests for SwiftUI UI related code. You can have a look at these:
- ViewInspector
- Snapshot testing
- Preference testing
The XCTest team is aware of the missing test tool. I hope that in the not so far future we will see some much needed UI testing tools that fit in the developer workflow.
Summary
In this post, you learned how to:
- Set up XCUITest to write UI tests for a SwiftUI iOS app
- Use accessibility identifiers to reliably find UI elements
- Validate real user flows like adding and deleting items
- Understand the trade-offs of UI testing: high value, but slower and more fragile than unit tests
UI tests won’t replace your unit tests—but they fill a gap unit tests can’t: verifying that the app behaves correctly from the user’s point of view.
⬇️ Project files: https://github.com/gahntpo/iOS-testing
🚀 What’s Next?
- Testing SwiftUI navigation and sheet logic
- Handling async waits and animations without flakiness
- Using
ViewInspector
for fast, logic-level UI tests - Snapshot testing for visual regressions