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>
This commit is contained in:
122
Docs/XCUITest-Authoring.md
Normal file
122
Docs/XCUITest-Authoring.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 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").
|
||||
65
Docs/uiTestPrompt.md
Normal file
65
Docs/uiTestPrompt.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# UI Test Generation Prompt Template
|
||||
|
||||
Use this prompt when asking an AI to generate a new UI test for PlantGuide.
|
||||
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
Write a UI test for **[FEATURE DESCRIPTION]** in PlantGuide.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Inherit from `BaseUITestCase` (not `XCTestCase`)
|
||||
- Import only `XCTest`
|
||||
- Mark test methods `@MainActor`
|
||||
- Launch with `launchClean()`, `launchWithMockData()`, or `launchOffline()` as appropriate
|
||||
- Navigate using screen objects: `TabBarScreen(app: app).tapCollection()`
|
||||
- Locate elements via `UITestID.*` identifiers, not localized strings
|
||||
- Wait with `waitForExistence(timeout:)`, `waitUntilHittable()`, `waitUntilGone()`
|
||||
- Never use `sleep()`
|
||||
- One assertion focus per test method
|
||||
- Use Given/When/Then comments for clarity
|
||||
|
||||
### File Structure
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
final class [Feature]UITests: BaseUITestCase {
|
||||
|
||||
@MainActor
|
||||
func test[Behavior]() throws {
|
||||
// Given
|
||||
launchWithMockData()
|
||||
let screen = TabBarScreen(app: app).tap[Tab]()
|
||||
XCTAssertTrue(screen.waitForLoad())
|
||||
|
||||
// When
|
||||
screen.[element].tapWhenReady()
|
||||
|
||||
// Then
|
||||
XCTAssertTrue([assertion])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available Screen Objects
|
||||
|
||||
- `TabBarScreen` -- `tapCamera()`, `tapCollection()`, `tapToday()`, `tapSettings()`
|
||||
- `CameraScreen` -- `captureButton`, `hasValidState()`
|
||||
- `CollectionScreen` -- `searchField`, `filterButton`, `viewModeToggle`, `emptyStateView`
|
||||
- `TodayScreen` -- `todaySection`, `overdueSection`, `emptyStateView`
|
||||
- `SettingsScreen` -- `clearCacheButton`, `notificationsToggle`, `versionInfo`
|
||||
- `PlantDetailScreen` -- `plantName`, `favoriteButton`, `editButton`, `deleteButton`
|
||||
|
||||
### Available Identifiers
|
||||
|
||||
See `PlantGuideUITests/Foundation/UITestID.swift` for the full list.
|
||||
All identifiers mirror `PlantGuide/Core/Utilities/AccessibilityIdentifiers.swift`.
|
||||
|
||||
### If an identifier is missing
|
||||
|
||||
1. Add it to `AccessibilityIdentifiers.swift` in the app
|
||||
2. Add `.accessibilityIdentifier(...)` to the view
|
||||
3. Mirror it in `UITestID.swift` in the test target
|
||||
Reference in New Issue
Block a user