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

123 lines
3.7 KiB
Markdown

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