Files
PlantGuide/Docs/XCUITest-Authoring.md
Trey t 1ae9c884c8 Rebuild UI test foundation with page objects, wait helpers, and screen objects
Replace brittle localized-string selectors and broken wait helpers with a
robust, identifier-first UI test infrastructure. All 41 UI tests pass on
iOS 26.2 simulator (iPhone 17).

Foundation:
- BaseUITestCase with deterministic launch helpers (launchClean, launchOffline)
- WaitHelpers (waitUntilHittable, waitUntilGone, tapWhenReady) replacing sleep()
- UITestID enum mirroring AccessibilityIdentifiers from the app target
- Screen objects: TabBarScreen, CameraScreen, CollectionScreen, TodayScreen,
  SettingsScreen, PlantDetailScreen

Key fixes:
- Tab navigation uses waitForExistence+tap instead of isHittable (unreliable
  in iOS 26 simulator)
- Tests handle real app state (empty collection, no camera permission)
- Increased timeouts for parallel clone execution
- Added NetworkMonitorProtocol and protocol-typed DI for testability
- Fixed actor-isolation issues in unit test mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:36:54 -06:00

3.7 KiB

XCUITest Authoring Guide

Architecture

PlantGuideUITests/
  Foundation/
    BaseUITestCase.swift     # Base class all tests inherit from
    UITestID.swift           # Mirrors AccessibilityIdentifiers from the app
    WaitHelpers.swift        # Centralized predicate-based waits
  Helpers/
    XCUIApplication+Launch.swift  # LaunchConfigKey constants
  Screens/                   # Page/screen objects
    TabBarScreen.swift
    CameraScreen.swift
    CollectionScreen.swift
    TodayScreen.swift
    SettingsScreen.swift
    PlantDetailScreen.swift
  # Test files (one per feature flow)
  NavigationUITests.swift
  CameraFlowUITests.swift
  CollectionFlowUITests.swift
  SettingsFlowUITests.swift
  AccessibilityUITests.swift
  PlantGuideUITests.swift         # Smoke tests
  PlantGuideUITestsLaunchTests.swift  # Screenshot capture

Rules

  1. Every test class inherits BaseUITestCase -- not XCTestCase.
  2. Launch via helpers -- launchClean(), launchWithMockData(), launchOffline().
  3. Locate elements by accessibility identifier (UITestID.*), never by localized text.
  4. Use screen objects for navigation and assertions (e.g., TabBarScreen, CollectionScreen).
  5. No sleep() -- use waitForExistence(timeout:), waitUntilHittable(), or waitUntilGone().
  6. Tests must be deterministic -- launch args control fixtures; no dependency on device state.
  7. One assertion focus per test -- if testing collection empty state, don't also test search.

Writing a New Test

Step 1 - Add identifiers (if needed)

In the app source, add .accessibilityIdentifier(AccessibilityIdentifiers.Foo.bar). Mirror the ID in UITestID.Foo.bar.

Step 2 - Create or extend a screen object

struct FooScreen {
    let app: XCUIApplication

    var myButton: XCUIElement {
        app.buttons[UITestID.Foo.myButton]
    }

    @discardableResult
    func waitForLoad(timeout: TimeInterval = 5) -> Bool {
        app.navigationBars["Foo"].waitForExistence(timeout: timeout)
    }
}

Step 3 - Write the test

final class FooFlowUITests: BaseUITestCase {

    @MainActor
    func testMyFeature() throws {
        launchWithMockData()
        let foo = TabBarScreen(app: app).tapFoo()
        XCTAssertTrue(foo.waitForLoad())
        foo.myButton.tapWhenReady()
        // assert...
    }
}

Step 4 - Verify compile

xcodebuild build-for-testing \
  -project PlantGuide.xcodeproj \
  -scheme PlantGuide \
  -destination 'platform=iOS Simulator,name=iPhone 17' \
  -quiet

Launch Configurations

Helper State Data Network
launchClean() Fresh None Online
launchWithMockData() Seeded Mock Online
launchOffline() Fresh None Offline

All launchers pass -UITesting and -SkipOnboarding.

Wait Helpers (WaitHelpers.swift)

Method Purpose
waitUntilHittable() Element exists AND is tappable
waitUntilGone() Element has disappeared
waitForValue(_:) Element value matches string
tapWhenReady() Wait then tap
app.waitForLaunch() Tab bar appeared after launch
app.waitForElement(id:) Any descendant by identifier

Tab Labels

The app has 4 tabs. Use UITestID.TabBar.*:

Tab Label
Camera "Camera"
Collection "Collection"
Today "Today"
Settings "Settings"

Note: The third tab is "Today" (not "Care").