UI Automation Testing with Screenshots
Goal
Automated testing of user flows with screenshot capture for:
- Regression testing (detect visual/functional changes)
- Documentation (visual record of app behavior)
- CI integration (automated verification)
Options Evaluated
| Framework | Type | Screenshots | Setup | Maintenance | Verdict |
|---|---|---|---|---|---|
| XCUITest | Native Swift | Manual | 2-3 hrs | Medium | Selected |
| swift-snapshot-testing | Visual diff | Comparison | 1 hr | Low | Add-on |
| Maestro | YAML | Built-in | 30 min | Low | Alternative |
| EarlGrey 2.0 | White-box | Manual | 4+ hrs | High | Overkill |
| Appium | Cross-platform | Manual | 4+ hrs | High | Too heavy |
Selected: XCUITest + swift-snapshot-testing
Apple’s native UI testing framework combined with Point-Free’s snapshot testing library.
Why XCUITest
- Native to Xcode - No external dependencies
- First-class iOS/Mac Catalyst support - Full platform integration
- Visual regression - Pixel-by-pixel comparison with swift-snapshot-testing
- Real device support - Test on actual hardware
- Full control - Access to all XCUIElement APIs
- 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:
| Element | accessibilityIdentifier |
|---|---|
| Floating play button | floatingPlayButton |
| Stop button | stopTimerButton |
| Save button | saveButton |
| Cancel button | cancelButton |
| Settings gear | settingsButton |
| Export/Share button | exportButton |
| Add tag button | addTagButton |
| Tag text field | tagTextField |
| Comment text field | commentTextField |
| Undo button | undoDeleteButton |
Add in SwiftUI:
Button("Stop") { ... }
.accessibilityIdentifier("stopTimerButton") Implementation Order
- Add UI Test target to project.yml
- Add swift-snapshot-testing package
- Create directory structure (UITests/, Flows/, Snapshots/)
- Add accessibility identifiers to key UI elements
- Create base test class (TimeTrackerUITests.swift)
- Write flow tests (TimerFlowTests, RecordEditingTests, etc.)
- Run with
isRecording = trueto generate baseline snapshots - Set up CI workflow
References
- swift-snapshot-testing - Point-Free
- XCUITest Guide - BrowserStack
- Apple XCTest Documentation
Related
- 601-testing-guide - Manual testing procedures
- 910-backlog - Backlog item for this feature