# 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 ```swift 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 ```swift 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 ```bash 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").