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>
3.7 KiB
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
- Every test class inherits
BaseUITestCase-- notXCTestCase. - Launch via helpers --
launchClean(),launchWithMockData(),launchOffline(). - Locate elements by accessibility identifier (
UITestID.*), never by localized text. - Use screen objects for navigation and assertions (e.g.,
TabBarScreen,CollectionScreen). - No
sleep()-- usewaitForExistence(timeout:),waitUntilHittable(), orwaitUntilGone(). - Tests must be deterministic -- launch args control fixtures; no dependency on device state.
- 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").