iOS App Testing
XCTest, Swift Testing, XCUITest and the accessibility identifiers that make UI automation possible — plus what to run on a simulator and what needs a device.
2 min read · updated 19 September 2026
iOS has two layers of testing tooling: unit tests in the app's process, and UI tests in a separate process driving the app through the accessibility system.
#Unit tests: Swift Testing
Swift Testing is the modern framework and a significant improvement on XCTest's conventions.
import Testing
@testable import Shop
@Suite("Pricing")
struct PricingTests {
@Test("an empty basket costs nothing")
func emptyBasket() {
#expect(Basket().totalCents == 0)
}
// Parameterised, natively. Each case is reported separately.
@Test("discount applies at or above the threshold", arguments: [
(12_000, 2_400),
(10_000, 2_000),
(9_999, 0)
])
func discount(subtotal: Int, expected: Int) {
let result = Pricing.applyDiscount(Order(subtotalCents: subtotal), policy: .standard)
#expect(result.discountCents == expected)
}
@Test func reservingMoreThanStockThrows() throws {
let service = OrderService(inventory: StubInventory(available: 1))
#expect(throws: InsufficientStock.self) {
try service.reserve(sku: "book-1", quantity: 2)
}
}
}#expect prints the whole expression and its sub-values on failure, which
is a real improvement over XCTAssertEqual's two-value message.
// XCTest, still everywhere and still supported
final class PricingTests: XCTestCase {
func test_discount_applies_at_the_threshold() {
XCTAssertEqual(Pricing.applyDiscount(Order(10_000), .standard).discountCents, 2_000)
}
}#UI tests: XCUITest
final class CheckoutUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
// Launch arguments are how you inject test state — the app reads
// them at startup and swaps in stubs. This is the iOS equivalent of
// setting up through the API.
app.launchArguments = ["-uiTesting", "-seedBasket", "book-1:2"]
app.launch()
}
func test_paying_shows_a_confirmation() {
app.textFields["card-number"].tap()
app.textFields["card-number"].typeText("4242424242424242")
app.buttons["pay"].tap()
// XCUITest queries wait automatically up to the given timeout.
XCTAssertTrue(app.staticTexts["order-confirmed"].waitForExistence(timeout: 10))
}
}#Accessibility identifiers are the whole game
// SwiftUI
Button("Pay £27.95") { viewModel.pay() }
.accessibilityIdentifier("pay")
TextField("Card number", text: $card)
.accessibilityIdentifier("card-number")
// UIKit
payButton.accessibilityIdentifier = "pay"Without identifiers you are matching on visible label text, which breaks on
every copy change and on every localisation. With them the suite is stable,
and the same identifiers make Appium work for free —
~pay resolves to accessibilityIdentifier on iOS and content-desc on
Android, so one locator covers both platforms.
#Test plans
// Shop.xctestplan — parallel, randomised, with a leak check
{
"configurations": [{
"name": "Default",
"options": {
"testTimeoutsEnabled": true,
"maximumTestExecutionTimeAllowance": 120,
"testExecutionOrdering": "random",
"userAttachmentLifetime": "keepNever"
}
}],
"defaultOptions": {
"codeCoverage": { "targets": [{ "name": "Shop" }] },
"environmentVariableEntries": [{ "key": "IS_UITEST", "value": "1" }]
}
}testExecutionOrdering: random is worth switching on immediately — it
surfaces order dependence, the same reason pytest-randomly is recommended
in pytest.
#Running in CI
xcodebuild test \
-scheme Shop \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' \
-testPlan Shop \
-parallel-testing-enabled YES \
-parallel-testing-worker-count 4 \
-resultBundlePath artifacts/Shop.xcresult \
-enableCodeCoverage YESThe .xcresult bundle contains failures, attachments, screenshots and
coverage. Publish it as a CI artifact — it is the
richest single diagnostic iOS produces, and most pipelines throw it away.
Note that iOS builds require macOS runners, which on most hosted CI cost roughly ten times a Linux minute. That cost is the main constraint on how much UI testing an iOS project can afford, and it is a strong argument for pushing logic down into unit tests.
#Snapshot testing
// pointfreeco/swift-snapshot-testing — a de facto standard on iOS
func test_checkout_view_appearance() {
let view = CheckoutView(viewModel: .paid)
assertSnapshot(of: view, as: .image(on: .iPhone13))
assertSnapshot(of: view, as: .image(on: .iPhone13(.landscape)))
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13), traits: .init(preferredContentSizeCategory: .accessibilityExtraLarge)))
}That third case — largest dynamic type — catches the single most common iOS layout defect there is. It is also almost never tested manually.
The usual snapshot caveats apply, plus an iOS-specific one: image snapshots are device- and OS-version-specific, so pin the simulator version in CI or the baselines will churn on every Xcode update.
#Simulator versus device
Simulator: functional flows, layout, most logic. Fast, parallel, cheap.
Real device: camera, biometrics, push notifications, background modes, real network conditions, memory pressure, thermal behaviour, actual performance. A small suite on a device cloud covers this — see Appium.
Common questions
- XCUITest or Appium for iOS?
- XCUITest if iOS is a separate app with its own team — it is faster, more stable and runs inside Xcode's tooling. Appium if one team tests both platforms and the journeys are the same, because a shared suite is worth some speed.
- Is the simulator good enough?
- For most functional testing, yes. What it cannot tell you is anything about real hardware — camera, biometrics, push notifications, real network conditions, memory pressure and battery behaviour — so a small real-device suite remains necessary.
- What is Swift Testing?
- Apple's newer testing framework, introduced with Swift 6 and integrated into Xcode 16. It uses macros — @Test and #expect — instead of XCTest's naming conventions, supports parameterised tests natively and runs tests in parallel by default. It coexists with XCTest; XCUITest still uses XCTest.
Runnable samples for this page
- reference only
reference/platforms/ios-app-testingreference only
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Android App TestingJUnit and Robolectric for local tests, Espresso and Compose for instrumented UI, and the emulator strategy that keeps an Android suite affordable in CI.
- AppiumOne WebDriver-based API across iOS and Android — how the drivers map onto XCUITest and UIAutomator, locator strategies, and the cost of cross-platform tests.
- Flutter TestingFlutter renders to a canvas with no native accessibility tree, so it brings its own three-layer test harness — unit, widget and integration — plus golden files.
- The Page Object ModelEncapsulating a screen behind a class so tests describe intent rather than selectors — what it fixes, the god-object failure mode, and modern alternatives.
- ScreenshotsCapturing the screen at the moment of failure, and using screenshot comparison as visual regression testing — configuration, masking and the flakiness to design out.
- PlaywrightThe default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.