XCUITest is Apple's UI testing framework for iOS and macOS apps. It ships with Xcode. There's nothing to install, no dependencies to configure, no server to run. Open your Xcode project, add a UI Testing target, and start writing tests in Swift.
That simplicity is its biggest strength and its biggest limitation. XCUITest is fast, stable, and tightly integrated with iOS. It's also iOS-only, Swift/Objective-C-only, and can't interact with system-level UI (permission dialogs, push notifications, Apple payment sheet). If you need cross-platform testing or system dialog handling, you need a different tool.
In this we covers setup, core API, three real test examples, and an honest assessment of where XCUITest fits alongside alternatives. For broader iOS testing picture (Simulator, TestFlight, physical devices, cloud testing), see our iOS testing guide.
Setup (5 minutes)
Step 1: Add a UI Testing target. In your Xcode project, go to File > New > Target. Select "UI Testing Bundle." Name it (e.g., "MyAppUITests"). Click Finish. Xcode creates a new test file with a template.
Step 2: Understand template. The generated file looks like this:
import XCTest
final class MyAppUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testExample() throws {
let app = XCUIApplication()
app.launch()
}
}β
XCUIApplication() is a proxy for your app. Calling .launch() starts app on Simulator or connected device. setUpWithError() runs before each test. continueAfterFailure = false means test stops at first failure instead of continuing and accumulating errors.
Step 3: Run it. Click diamond icon next to testExample() or press Cmd+U to run all tests. The Simulator launches, app opens, and test passes (because it doesn't assert anything yet). If this works, your setup is complete.
The core API: 3 classes you'll use constantly
XCUITest's API is small. Most tests use three classes.
XCUIApplication. Launches, monitors, and terminates app. You create one instance per test. app.launch() starts fresh. app.terminate() kills it. You can also set launch arguments and environment variables before launching to configure test-specific behavior (like toggling feature flags or setting a test API endpoint).
XCUIElementQuery. Finds elements on screen. Every query starts from app and narrows down by type and identifier. app.buttons["Log in"] finds a button labeled "Log in." app.textFields["Email"] finds a text field with accessibility label "Email." app.staticTexts["Welcome"] finds a label containing "Welcome."
XCUIElement. A single UI element. Once you've found it via a query, you interact with it: .tap(), .typeText("hello"), .swipeUp(), .exists (boolean check). XCUIElement waits automatically for element to appear before interacting, which eliminates most timing issues. The default timeout is about 5 seconds.
Test 1: login with valid credentials
This test finds email field by its accessibility label ("Email"), types credentials, taps Login button, and asserts that welcome text appears within 10 seconds. If login flow takes longer than 10 seconds or welcome text doesn't appear, test fails.
The waitForExistence(timeout:) call is how you handle async behavior in XCUITest. The app might need a few seconds to authenticate and navigate to home screen. The wait replaces sleep() hacks you'd use in less sophisticated frameworks.
Test 2: navigation flow
func testNavigateToProfileScreen() throws {
let app = XCUIApplication()
app.launch()
// Assume user is already logged in (set via launch argument)
app.launchArguments += ["--skip-login"]
app.launch()
app.tabBars.buttons["Profile"].tap()
XCTAssertTrue(app.staticTexts["Account Settings"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["Edit Profile"].exists)
XCTAssertTrue(app.buttons["Log Out"].exists)
}β
This test uses launchArguments to skip login flow. This is a common pattern: configure app's state via launch arguments (or environment variables) so each test starts from a known state without repeating login steps. Your app code checks for --skip-login and bypasses authentication in test builds.
The test then taps Profile tab, verifies "Account Settings" label is present, and checks that "Edit Profile" and "Log Out" buttons exist. Simple but effective for catching navigation regressions.
Test 3: accessibility check
func testAccessibilityLabelsExist() throws {
let app = XCUIApplication()
app.launch()
// Verify key elements have accessibility labels
XCTAssertTrue(app.buttons["Log in"].isHittable)
XCTAssertTrue(app.textFields["Email"].isHittable)
XCTAssertTrue(app.secureTextFields["Password"].isHittable)
}β
This is a minimal accessibility test. isHittable checks that element is on screen, visible, and tappable. If a developer removes an accessibility label or a button gets pushed off screen by a layout change, this test catches it.
XCUITest is built on Apple's accessibility infrastructure. Every element query uses accessibility identifiers, labels, and traits. This means well-written XCUITest tests double as accessibility validation: if XCUITest can't find an element, neither can VoiceOver.
What XCUITest can't do
Every tutorial should cover limitations honestly. Here are four.
Can't interact with system dialogs. Permission prompts ("Allow access to location?"), Apple Pay sheet, and notification authorization dialogs are rendered by iOS, not by your app. XCUITest runs inside your app's process and can't tap buttons on system UI. Xcode provides addUIInterruptionMonitor as a workaround, but it's unreliable for complex flows. This is single most common frustration with XCUITest.
iOS-only. If your team ships on Android too, XCUITest covers half your testing. You need Espresso for Android, which means maintaining two separate test suites in two different languages. That's double authoring and double maintenance.
Depends on accessibility identifiers. app.buttons["Log in"] works because button has an accessibility label. If developer changes label from "Log in" to "Sign in," test breaks. The app works fine. The test is stale. At 200+ tests, identifier management becomes a real maintenance cost, similar to selector problem in Appium.
Simulator-first. XCUITest runs fastest on iOS Simulator. Running on physical devices is slower and requires a provisioning profile. Most teams run XCUITest on Simulator in CI and only test on real devices before release. But Simulator doesn't reproduce real-device behavior: actual GPU rendering, thermal throttling, or memory pressure from other apps. For more on this gap, see our emulator vs real device comparison.
Where XCUITest fits vs alternatives
XCUITest is right choice when your team writes Swift, you only support iOS, and you want fast, stable UI tests that run in CI on Simulator. It's iOS equivalent of Espresso on Android.
Appium is right choice when you need cross-platform coverage (Android + iOS) with one framework and you have automation engineers comfortable with code. The trade-off is speed (slower than XCUITest) and maintenance (selectors break more often).
Drizz is right choice when you want cross-platform E2E on real devices without maintaining separate test suites or selector libraries. Tests are written in plain English and run on both Android and iOS. Vision AI finds elements visually instead of by accessibility identifiers, so renamed labels don't break tests. The popup agent handles system dialogs that XCUITest can't.
For full framework comparison across Appium, Espresso, XCUITest, Maestro, and Drizz, see our XCUITest vs Appium vs Drizz comparison.
Integrating XCUITest into CI/CD
XCUITest runs in CI via xcodebuild test or Fastlane's scan action. A typical GitHub Actions workflow:
- name: Run UI Tests
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=18.0' \
-resultBundlePath TestResultsβ
This runs all XCUITest tests on an iPhone 15 Simulator with iOS 18. The result bundle contains pass/fail results, screenshots on failure, and performance metrics. Parse results to gate your pipeline: if any test fails, build doesn't proceed to TestFlight or App Store.
For full CI/CD pipeline architecture including where XCUITest fits alongside real-device testing, see our mobile CI/CD guide.
FAQ
What is XCUITest?
Apple's native UI testing framework for iOS and macOS apps. It's part of XCTest, ships with Xcode, and tests are written in Swift or Objective-C. No installation needed.
Do I need Swift to use XCUITest?
Yes. Tests are written in Swift (or Objective-C, though Swift is standard in 2026). If your team doesn't write Swift, you need a cross-platform tool like Appium or Drizz instead.
Can XCUITest test Android apps?
No. XCUITest is iOS-only. For Android, use Espresso (native) or Appium (cross-platform). For both platforms from one test suite, use Drizz or Appium.
Why can't XCUITest handle permission dialogs?
Permission dialogs are system-level UI rendered by iOS, not by your app. XCUITest runs inside your app's process and can only interact with your app's UI. Xcode provides addUIInterruptionMonitor as a partial workaround, but it's unreliable for complex multi-dialog flows.
How is XCUITest different from XCTest?
XCTest is broader framework for unit tests, integration tests, and performance tests. XCUITest is UI testing module within XCTest. XCTest tests your code. XCUITest tests your interface.
Should I use XCUITest or Appium for iOS testing?
XCUITest if you're iOS-only, your team writes Swift, and you want speed. Appium if you need Android + iOS from one framework. Both use accessibility identifiers and require maintenance when UI changes.
β


