UI Testing Quick Guide

Have you tried to use Xcode to write UI tests or automate your screenshots with fastlane? The documentation for iOS UI Testing leaves much room for improvement. The class documentation doesn’t help unless you already know what to do. Here’s my reference guide to get you started:

Last updated: May 19, 2021

Setup UI Tests

  1. Create the UI Test target when you create the Xcode project or add it later with File > New > Target...:

    Create UI Test Bundle

    Note: You can write UI tests in Swift even if you’re testing an application written in Objective-C.

  2. Use the Test action in the scheme editor (Product > Scheme > Edit Scheme...) to control which tests are enabled:

    Scheme Test Action

    Use the Options button to run tests in parallel (faster) and randomize order to uncover any dependencies between tests:

    Test options

    New tests are enabled by default.

  3. Setup and launch application:

    // UITests.swift
    import XCTest
    
    class UITests: XCTestCase {
    
      let app = XCUIApplication()
    
      override func setUp() {
        continueAfterFailure = false
        app.launch()
      }
    
      override func tearDown() { ... }
    
      func testSomething() { ... }
    }
    

Writing UI Tests

  • Setup App
  • Query user interface and perform action(s)
  • Assert expected state

Test User Interface with two buttons and a label

func testStart() {
  let startButton = app.buttons["startButton"]
  let stateLabel = app.staticTexts["stateLabel"]
  startButton.tap()
  XCTAssertEqual(stateLabel.label, "Running")
}

Xcode recording

  • Place cursor in UI test method and use the record button to start/stop
  • Create new or add to an existing test
  • Can be useful when learning but I find it slow and unreliable

Record test

If your writing your tests manually (without recording) try printing the the element subtree for the application to see what you can query for (element types, identifier and label):

print(app.debugDescription)

Element subtree:
  Application, 0x600003a4cf70, pid: 25509, label: 'MyApp'
    Window (Main), 0x600003a4d1e0, {{0.0, 0.0}, {375.0, 667.0}}
      Other, 0x600003a4d2b0, {{0.0, 0.0}, {375.0, 667.0}}
        NavigationBar, 0x600003a4d380, {{0.0, 20.0}, {375.0, 44.0}}, identifier: 'Test'
          Other, 0x600003a4d450, {{170.5, 32.0}, {34.0, 20.5}}, label: 'Test'
        Other, 0x600003a4d520, {{0.0, 0.0}, {375.0, 667.0}}
          Other, 0x600003a4d5f0, {{0.0, 0.0}, {375.0, 667.0}}
            Other, 0x600003a4d6c0, {{0.0, 0.0}, {375.0, 667.0}}, identifier: 'rootView'
              Other, 0x600003a4d790, {{20.0, 84.0}, {335.0, 92.5}}
                StaticText, 0x600003a4d860, {{20.0, 84.0}, {335.0, 31.5}}, identifier: 'stateLabel', label: 'Waiting...'
                Other, 0x600003a4d930, {{20.0, 135.5}, {335.0, 41.0}}
                  Button, 0x600003a4da00, {{20.0, 135.5}, {163.5, 41.0}}, identifier: 'stopButton', label: 'Stop'
                  Button, 0x600003a4dad0, {{191.5, 135.5}, {163.5, 41.0}}, identifier: 'startButton', label: 'Start'

Accessibility Identifier

Set the accessibility identifier on an element you want to query rather than relying on a text label or title which might be localized:

Accessibility identifier

let startButton = app.buttons["startButton"]

Note: Element must be enabled for accessibility identifier to be visible to UI test.

System Alerts

To dismiss system alerts that might otherwise interrupt UI tests, add to setUp():

addUIInterruptionMonitor(withDescription: "System Dialog") { (alert) -> Bool in
   // Tap "Allow" button
   alert.buttons["Allow"].tap()
   return true
 }
 // Need to interact with App
 app.tap()

See Handling System Alerts In UI Tests for further details on handling different types of alerts.

XCUIApplication

XCUIApplication is a proxy for your App (the App runs in a separate process):

  • Use to launch, activate or terminate App. Only one instance can run at a time.

  • Set launch arguments and launch environment.

  • Root of all queries for elements.

    let app = XCUIApplication()
    app.launchArguments += ["-enableTestMode","1"]
    app.launchEnvironment["DEBUG_LEVEL"] = "5"
    app.launch()
    

Use launch(), activate(), or terminate to change the state of the App being tested:

  • Synchronous methods, App state has changed on return or test fails.

  • launch() launches App, terminating any existing instance.

  • activate() launches App if not running else brings a running instance to the foreground.

  • Simulate home button press to send App to the background.

    // Send to background and wait for state change
    XCUIDevice.shared.press(.home)
    let background = app.wait(for: .runningBackground, timeout: 5)
    XCTAssertTrue(background)
    
    // Launch or bring to foreground - synchronous
    app.activate()
    XCTAssertTrue(app.state == .runningForeground)
    
    // Kill the App
    app.terminate()
    
  • Useful to keep a reference to App instance but be careful. A common mistake when using Fastlane snapshot is to configure one App instance but launch another:

    let app = XCUIApplication()
    setupSnapshot(app)
    
    // Forgot to change template, launches a new instance
    XCUIApplication().launch() // oops
    
    // Launch the configured App instance
    app.launch() // OK
    

XCUIDevice

  • Simulate user interacting with a device

    // Get current device
    let device = XCUIDevice.shared
    
  • Set device orientation (useful to add to setUp for screenshots):

    // Rotate iPhone to portrait, iPad to landscape
    if UIDevice.current.userInterfaceIdiom == .phone {
      device.orientation = .portrait
    } else {
      device.orientation = .landscapeRight
    }
    
  • Press buttons:

    // Press Home button
    device.press(.home)
    
    #if !targetEnvironment(simulator)
      // Not available in the simulator
      device.press(.volumeUp)
    #endif
    

    Note: The volumeUp and volumeDown buttons are not available when testing in the simulator.

  • Send text to Siri:

    let siri = device.siriService
    siri.activate(voiceRecognitionText: "Play recording")
    

XCUIElementQuery

Querying the application proxy for elements:

  • Application instance is at the root of the element tree

  • Query returns a collection of elements

  • Query by identifier (label, title, accessibility identifier), element type or with a predicate.

    // See XCUIElement.ElementType for full list
    .button 
    .cell            // table or collection view cells
    .collectionView
    .image
    .navigationBar
    .scrollView
    .staticText      // text labels
    .switch
    .tabBar
    .table
    .textField
    
  • Query for descendant element:

    // Find all buttons
    let buttonQuery = app.descendants(matching: .button)
    XCTAssertTrue(buttonQuery.count == 2)
    
    // Find all text labels
    let allLabels = app.descendants(matching: .staticText)
    
  • Descendant queries are so common that there is a convenience API for each of the element types:

    // app.descendants(matching: .button)
    let allButtons = app.buttons
    
    let allLabels = app.staticTexts
    let allTables = app.tables
    let allCells = app.cells // app.tables.cells
    
  • When you only want a direct (child) descendant:

    // Find nav bar button
    let addButton = app.navigationBars.children(matching: .button)
    
  • To query for elements containing a type with identifier:

    let cellQuery = app.cells.containing(.staticText, identifier: "urgentItem")
    
  • Querying With Predicates (matching or containing):

    let cellQuery = cells.matching(NSPredicate(format: "label BEGINSWITH %@", title))
    

XCUIElement

You interact with and test properties of an XCUIElement:

  • The element is the result of a query that matches one unique instance.

  • A number of methods will return a single element from a query.

  • Use Element when a query should only return one element (fails if there are multiple elements).

  • Use firstMatch when you only care about finding the first element from many.

  • Getting an element from a query:

    // Query for all buttons
    let buttonQuery = app.descendants(matching: .button)
    
    // Get element with subscript matching identifier
    let stopButton = buttonQuery["stopButton"]
    
    // Get element by index
    let stopButton = buttonQuery.element(boundBy: 0)
    
    // When query must only return a single unique element
    // - will fail if matches multiple elements
    let textField = app.textFields.element
    
    // When you want only the first match
    // - speeds up test by stopping query when match is found
    let firstButton = buttonQuery.firstMatch
    
  • Check for element to exist:

    let startButton = app.buttons["startButton"]
    XCTAssertTrue(startButton.exists)
    
  • Wait for element to appear (test fails if timeout expires before element exists):

    let exists = startButton.waitForExistence(timeout: 3.0)
    XCTAssertTrue(exists,"Missing start button")
    
  • Check if element is onscreen and is tappable:

    if startButton.isHittable {
      // Do something with button
    }
    
  • Taps and long press:

    startButton.tap()
    startButton.press(forDuration: 3)
    
    scrollView.doubleTap()
    scrollView.twoFingerTap()
    scrollView.tap(withNumberOfTaps: 1, numberOfTouches: 4)
    
  • Dragging views:

    let view1 = app.otherElements["view1"]
    let view2 = app.otherElements["view2"]
    view1.press(forDuration: 1, thenDragTo: view2)
    
  • Swipe gestures:

    scrollView.swipeUp()
    scrollView.swipeRight()
    
  • Pinch and rotate

    scrollView.pinch(withScale: 2, velocity: 1)
    scrollView.rotate(.pi/2, withVelocity: 2)
    
  • Type text into a text field (must have focus):

    let textField = app.textFields.firstMatch
    textField.tap()
    textField.typeText("Hello")
    
  • Move slider

    let slider = app.sliders.firstMatch
    slider.adjust(toNormalizedSliderPosition: 0.5)
    

XCUIElementAttributes

Properties of an XCUIElement that you can query or test:

  • Identity and type

    XCTAssertTrue(stopButton.identifier == "stopButton")
    XCTAssertTrue(stopButton.label == "Stop")
    XCTAssertTrue(stopButton.elementType == .button)
    
  • Element state

    if textField.hasFocus { ... }    // tvOS only
    if stopButton.isEnabled { ... }
    if cell.isSelected { ... }
    
  • View Frame and Size Class

    textField.frame // CGRect
    if myView.horizontalSizeClass == .compact { ... }
    

XCUICoordinate

Create a coordinate location on screen relative to a reference element (must exist):

let rootView = app.otherElements["rootView"]
let target = rootView.coordinate(withNormalizedOffset: CGVector(dx: 10, dy: 10))

Tap, double tap or press at coordinate location:

target.tap()
target.doubleTap()
target.press(forDuration: 3)

Create a destination offset from existing coordinate and then drag to it:

// Drag down 100 points
let endPoint = target.withOffset(CGVector(dx: 0, dy: 100))
target.press(forDuration: 3, thenDragTo: endPoint)

Learn More

Some other recent posts on UI Testing: