Skip to Content
Course content

199: UI Testing with XCUITest In Depth

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I’ve spent a lot of time reviewing PRs for UI tests, and there is one misconception that keeps popping up: the belief that XCUITest is essentially a "macro recorder" for your app's visual labels. I see developers writing tests that look for app.buttons["Submit Order"] or app.staticTexts["Welcome, User!"]. On the surface, it feels intuitive because that's what the user sees. But in reality, this is the fastest way to create a "flaky" test suite that breaks the moment a copywriter changes a word or you localize your app into Spanish.

Imagine you're building a checkout screen. You have a button that says "Confirm Purchase." If you write app.buttons["Confirm Purchase"].tap(), your test passes today. But tomorrow, the product manager decides it should say "Complete Order" to increase conversions. Suddenly, your CI pipeline is red, not because the feature is broken, but because a string changed. That's a maintenance nightmare I don't want you to inherit.

Stop Testing Labels, Start Testing Identifiers

The professional way to handle this is by decoupling the visual representation of a UI element from its identity in the test. We do this using the accessibilityIdentifier. This property is invisible to the end user and doesn't affect the voice-over experience, but it provides a rock-solid hook for XCUITest.

// In your App Code (ViewController or SwiftUI View)
confirmButton.accessibilityIdentifier = "checkout_confirm_button"

// In your UI Test
let app = XCUIApplication()
app.buttons["checkout_confirm_button"].tap()


Now, your copywriter can change the button text to "Buy Now," "Yes, Please!", or "Confirmar Compra," and your test won't blink. I always tell my juniors: if you're using a user-facing string in a XCUIElementQuery, you're writing a test for the copy, not the functionality.

Killing Flakiness with Explicit Waiting

Another deep-dive topic we need to cover is timing. UI tests are notoriously slow and unpredictable because they rely on the OS to render views and the network to return data. If you try to tap a button that hasn't appeared yet because a network request is still pending, XCUITest will throw an "Element not found" error and crash your test.

Don't use Thread.sleep(). Seriously, don't. It's a blunt instrument that makes your tests take forever. Instead, use waitForExistence(timeout:). This allows the test to proceed the millisecond the element appears, but gives it a reasonable window to show up before failing.

let successMessage = app.staticTexts["order_success_label"]

// Give the server up to 5 seconds to respond and the UI to update
if successMessage.waitForExistence(timeout: 5) {
    XCTAssertTrue(successMessage.exists)
} else {
    XCTFail("The order success message never appeared.")
}


Organizing with the Page Object Pattern

As your app grows, you'll find yourself repeating the same queries in ten different tests. If the hierarchy of your "Profile" screen changes, you'll have to update ten tests. To avoid this, I use a simplified Page Object Pattern. Essentially, you create a struct or class that represents a screen and houses all the queries for that screen.

struct CheckoutPage {
    let app = XCUIApplication()
    
    var confirmButton: XCUIElement {
        app.buttons["checkout_confirm_button"]
    }
    
    var totalAmountLabel: XCUIElement {
        app.staticTexts["checkout_total_label"]
    }
    
    func confirmPurchase() {
        confirmButton.tap()
    }
}

// In your actual test method:
func testSuccessfulCheckout() {
    let checkoutPage = CheckoutPage()
    XCTAssertTrue(checkoutPage.totalAmountLabel.exists)
    checkoutPage.confirmPurchase()
}


This makes your tests read like a story rather than a list of coordinates and identifiers. It's cleaner, and more importantly, it's maintainable.




📋 Practical Task

Hardening the E-Commerce Checkout Flow

You are tasked with fixing a brittle UI test for a simulated Shopping Cart app. Currently, the test fails whenever the network is slightly slow or when the "Pay Now" button text is changed for a holiday promotion.

Your requirements:

  • Modify the app code to add a stable accessibilityIdentifier to the "Pay Now" button (call it "payment_submit_button") and the "Order Confirmed" label (call it "order_confirmation_label").
  • Rewrite the testPaymentFlow() method to stop relying on the string "Pay Now".
  • Implement waitForExistence(timeout:) to ensure the "Order Confirmed" label is present before the test asserts its existence, accounting for a simulated 2-second network delay.
  • Refactor the test using a CartPage object to encapsulate the button and label queries.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.