UI Automation Testing with Screenshots

Goal

Automated testing of user flows with screenshot capture for:

  1. Regression testing (detect visual/functional changes)
  2. Documentation (visual record of app behavior)
  3. CI integration (automated verification)

Options Evaluated

FrameworkTypeScreenshotsSetupMaintenanceVerdict
XCUITestNative SwiftManual2-3 hrsMediumSelected
swift-snapshot-testingVisual diffComparison1 hrLowAdd-on
MaestroYAMLBuilt-in30 minLowAlternative
EarlGrey 2.0White-boxManual4+ hrsHighOverkill
AppiumCross-platformManual4+ hrsHighToo heavy

Selected: XCUITest + swift-snapshot-testing

Apple’s native UI testing framework combined with Point-Free’s snapshot testing library.

Why XCUITest

  1. Native to Xcode - No external dependencies
  2. First-class iOS/Mac Catalyst support - Full platform integration
  3. Visual regression - Pixel-by-pixel comparison with swift-snapshot-testing
  4. Real device support - Test on actual hardware
  5. Full control - Access to all XCUIElement APIs
  6. CI-ready - Run via xcodebuild test

swift-snapshot-testing Benefits

  • Captures screenshots and compares against baselines
  • Fails tests when UI changes unexpectedly
  • Supports any Swift value (images, views, JSON, etc.)
  • No configuration required

Sources: XCUITest Guide, swift-snapshot-testing


XCUITest Implementation

Phase 1: Setup

Add to project.yml:

targets:
  TimeTrackerUITests:
    type: bundle.ui-testing
    platform: iOS
    sources:
      - path: UITests
    dependencies:
      - target: TimeTracker
      - package: SnapshotTesting
    settings:
      TEST_HOST: ""

packages:
  SnapshotTesting:
    url: https://github.com/pointfreeco/swift-snapshot-testing
    from: 1.18.0

UITests/TimerFlowTests.swift:

import XCTest
import SnapshotTesting

final class TimerFlowTests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testStartAndStopTimer() throws {
        // Capture initial state
        assertSnapshot(of: app.screenshot().image, as: .image, named: "01-initial")

        // Tap floating play button
        app.buttons["floatingPlayButton"].tap()

        // Wait for UI to update
        let runningSection = app.staticTexts["Running Timers"]
        XCTAssertTrue(runningSection.waitForExistence(timeout: 2))

        assertSnapshot(of: app.screenshot().image, as: .image, named: "02-timer-running")

        // Stop timer
        app.buttons["Stop"].tap()

        assertSnapshot(of: app.screenshot().image, as: .image, named: "03-timer-stopped")
    }
}

Phase 2: Create Directory Structure

TimeTracker/
  UITests/
    TimeTrackerUITests.swift      # Base test class
    Flows/
      TimerFlowTests.swift
      RecordEditingTests.swift
      DeleteFlowTests.swift
      ExportFlowTests.swift
      TagManagementTests.swift
    __Snapshots__/                # Auto-generated baseline images

Phase 3: Base Test Class

UITests/TimeTrackerUITests.swift:

import XCTest
import SnapshotTesting

class TimeTrackerUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting"]
        app.launch()

        // Set simulator status bar for consistent screenshots
        // xcrun simctl status_bar "iPhone 17" override --time "9:41"
    }

    override func tearDownWithError() throws {
        app.terminate()
    }

    func snapshot(_ name: String, file: StaticString = #file, line: UInt = #line) {
        assertSnapshot(
            of: app.screenshot().image,
            as: .image,
            named: name,
            file: file,
            line: line
        )
    }

    func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
        element.waitForExistence(timeout: timeout)
    }
}

Phase 4: Flow Tests

UITests/Flows/TimerFlowTests.swift:

import XCTest
import SnapshotTesting

final class TimerFlowTests: TimeTrackerUITests {
    func testStartAndStopTimer() throws {
        snapshot("01-initial")

        // Start timer
        app.buttons["floatingPlayButton"].tap()
        XCTAssertTrue(waitForElement(app.staticTexts["Running Timers"]))
        snapshot("02-timer-running")

        // Stop timer
        app.buttons["stopTimerButton"].tap()
        XCTAssertTrue(waitForElement(app.staticTexts["Today"]))
        snapshot("03-timer-stopped")
    }
}

UITests/Flows/RecordEditingTests.swift:

import XCTest
import SnapshotTesting

final class RecordEditingTests: TimeTrackerUITests {
    func testEditCompletedRecord() throws {
        // Create a record first
        app.buttons["floatingPlayButton"].tap()
        _ = waitForElement(app.buttons["stopTimerButton"])
        app.buttons["stopTimerButton"].tap()
        snapshot("01-record-created")

        // Tap to edit
        app.cells.firstMatch.tap()
        snapshot("02-edit-mode")

        // Edit comment
        let commentField = app.textFields["commentTextField"]
        commentField.tap()
        commentField.typeText("Test comment")
        snapshot("03-comment-entered")

        // Save
        app.buttons["saveButton"].tap()
        snapshot("04-saved")
    }
}

UITests/Flows/DeleteFlowTests.swift:

import XCTest
import SnapshotTesting

final class DeleteFlowTests: TimeTrackerUITests {
    func testDeleteWithUndo() throws {
        // Create a record
        app.buttons["floatingPlayButton"].tap()
        _ = waitForElement(app.buttons["stopTimerButton"])
        app.buttons["stopTimerButton"].tap()
        snapshot("01-before-delete")

        // Swipe to delete
        let cell = app.cells.firstMatch
        cell.swipeLeft()
        app.buttons["Delete"].tap()
        snapshot("02-toast-visible")

        // Verify undo button
        XCTAssertTrue(app.buttons["undoDeleteButton"].exists)

        // Undo
        app.buttons["undoDeleteButton"].tap()
        snapshot("03-after-undo")
    }
}

UITests/Flows/ExportFlowTests.swift:

import XCTest
import SnapshotTesting

final class ExportFlowTests: TimeTrackerUITests {
    func testExportPDF() throws {
        snapshot("01-history")

        // Open export sheet
        app.buttons["exportButton"].tap()
        _ = waitForElement(app.staticTexts["PDF Report"])
        snapshot("02-export-sheet")

        // Select PDF
        app.buttons["PDF Report"].tap()
        snapshot("03-pdf-options")
    }
}

Phase 5: Run Tests

Run all UI tests:

xcodebuild test \
  -scheme TimeTracker \
  -destination 'platform=iOS Simulator,name=iPhone 17' \
  -only-testing:TimeTrackerUITests

Run specific test:

xcodebuild test \
  -scheme TimeTracker \
  -destination 'platform=iOS Simulator,name=iPhone 17' \
  -only-testing:TimeTrackerUITests/TimerFlowTests/testStartAndStopTimer

Record new baselines (first run or after intentional changes):

// In test file, temporarily set:
isRecording = true

Phase 6: CI Integration

.github/workflows/ui-tests.yml:

name: UI Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ui-tests:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Xcode
        uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: latest-stable

      - name: Build and Test
        run: |
          xcodebuild test \
            -scheme TimeTracker \
            -destination 'platform=iOS Simulator,name=iPhone 17' \
            -only-testing:TimeTrackerUITests \
            -resultBundlePath TestResults.xcresult

      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: TestResults.xcresult

Accessibility Labels Required

Add these identifiers to UI elements:

ElementaccessibilityIdentifier
Floating play buttonfloatingPlayButton
Stop buttonstopTimerButton
Save buttonsaveButton
Cancel buttoncancelButton
Settings gearsettingsButton
Export/Share buttonexportButton
Add tag buttonaddTagButton
Tag text fieldtagTextField
Comment text fieldcommentTextField
Undo buttonundoDeleteButton

Add in SwiftUI:

Button("Stop") { ... }
    .accessibilityIdentifier("stopTimerButton")

Implementation Order

  1. Add UI Test target to project.yml
  2. Add swift-snapshot-testing package
  3. Create directory structure (UITests/, Flows/, Snapshots/)
  4. Add accessibility identifiers to key UI elements
  5. Create base test class (TimeTrackerUITests.swift)
  6. Write flow tests (TimerFlowTests, RecordEditingTests, etc.)
  7. Run with isRecording = true to generate baseline snapshots
  8. Set up CI workflow

References

Related