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:
88
PlantGuideUITests/Foundation/BaseUITestCase.swift
Normal file
88
PlantGuideUITests/Foundation/BaseUITestCase.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// BaseUITestCase.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Base class for all PlantGuide UI tests.
|
||||
// Provides deterministic launch, fixture control, and shared helpers.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// Base class every UI test class should inherit from.
|
||||
///
|
||||
/// Provides:
|
||||
/// - Deterministic `app` instance with clean lifecycle
|
||||
/// - Convenience launchers for clean state, mock data, offline
|
||||
/// - Tab navigation via `navigateToTab(_:)`
|
||||
/// - Implicit `waitForLaunch()` after every launch helper
|
||||
class BaseUITestCase: XCTestCase {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The application under test. Reset for each test method.
|
||||
var app: XCUIApplication!
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Launch Helpers
|
||||
|
||||
/// Launches with a fresh database and no prior state.
|
||||
func launchClean() {
|
||||
app.launchArguments += [
|
||||
LaunchConfigKey.uiTesting,
|
||||
LaunchConfigKey.cleanState,
|
||||
LaunchConfigKey.skipOnboarding
|
||||
]
|
||||
app.launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
|
||||
app.launch()
|
||||
XCTAssertTrue(app.waitForLaunch(timeout: 15), "App did not launch (clean)")
|
||||
}
|
||||
|
||||
/// Launches with mock plants and care data pre-populated.
|
||||
func launchWithMockData(plantCount: Int = 5) {
|
||||
app.launchArguments += [
|
||||
LaunchConfigKey.uiTesting,
|
||||
LaunchConfigKey.mockData,
|
||||
LaunchConfigKey.skipOnboarding
|
||||
]
|
||||
app.launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
|
||||
app.launchEnvironment[LaunchConfigKey.useMockData] = "YES"
|
||||
app.launchEnvironment["MOCK_PLANT_COUNT"] = String(plantCount)
|
||||
app.launch()
|
||||
XCTAssertTrue(app.waitForLaunch(timeout: 15), "App did not launch (mock data)")
|
||||
}
|
||||
|
||||
/// Launches simulating no network.
|
||||
func launchOffline() {
|
||||
app.launchArguments += [
|
||||
LaunchConfigKey.uiTesting,
|
||||
LaunchConfigKey.offlineMode,
|
||||
LaunchConfigKey.skipOnboarding
|
||||
]
|
||||
app.launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
|
||||
app.launchEnvironment[LaunchConfigKey.isOfflineMode] = "YES"
|
||||
app.launch()
|
||||
XCTAssertTrue(app.waitForLaunch(timeout: 15), "App did not launch (offline)")
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
/// Taps a tab by its label (use `UITestID.TabBar.*`).
|
||||
func navigateToTab(_ tabLabel: String) {
|
||||
let tab = app.tabBars.buttons[tabLabel]
|
||||
XCTAssertTrue(tab.waitForExistence(timeout: 10),
|
||||
"Tab '\(tabLabel)' not found")
|
||||
tab.tap()
|
||||
}
|
||||
}
|
||||
127
PlantGuideUITests/Foundation/UITestID.swift
Normal file
127
PlantGuideUITests/Foundation/UITestID.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// UITestID.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Centralized UI test identifiers mirroring AccessibilityIdentifiers in the app.
|
||||
// Always use these constants to locate elements in UI tests.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Mirrors `AccessibilityIdentifiers` from the main app target.
|
||||
/// Keep in sync with PlantGuide/Core/Utilities/AccessibilityIdentifiers.swift
|
||||
enum UITestID {
|
||||
|
||||
// MARK: - Tab Bar
|
||||
|
||||
enum TabBar {
|
||||
static let tabBar = "main_tab_bar"
|
||||
static let camera = "Camera" // Tab label used by SwiftUI
|
||||
static let collection = "Collection"
|
||||
static let today = "Today"
|
||||
static let settings = "Settings"
|
||||
}
|
||||
|
||||
// MARK: - Camera
|
||||
|
||||
enum Camera {
|
||||
static let captureButton = "camera_capture_button"
|
||||
static let previewView = "camera_preview_view"
|
||||
static let switchCameraButton = "camera_switch_button"
|
||||
static let flashToggleButton = "camera_flash_toggle_button"
|
||||
static let photoLibraryButton = "camera_photo_library_button"
|
||||
static let closeButton = "camera_close_button"
|
||||
static let permissionDeniedView = "camera_permission_denied_view"
|
||||
static let openSettingsButton = "camera_open_settings_button"
|
||||
}
|
||||
|
||||
// MARK: - Collection
|
||||
|
||||
enum Collection {
|
||||
static let collectionView = "collection_view"
|
||||
static let searchField = "collection_search_field"
|
||||
static let viewModeToggle = "collection_view_mode_toggle"
|
||||
static let filterButton = "collection_filter_button"
|
||||
static let gridView = "collection_grid_view"
|
||||
static let listView = "collection_list_view"
|
||||
static let emptyStateView = "collection_empty_state_view"
|
||||
static let loadingIndicator = "collection_loading_indicator"
|
||||
static let plantGridItem = "collection_plant_grid_item"
|
||||
static let plantListRow = "collection_plant_list_row"
|
||||
static let favoriteButton = "collection_favorite_button"
|
||||
static let deleteAction = "collection_delete_action"
|
||||
}
|
||||
|
||||
// MARK: - Identification
|
||||
|
||||
enum Identification {
|
||||
static let identificationView = "identification_view"
|
||||
static let imagePreview = "identification_image_preview"
|
||||
static let loadingIndicator = "identification_loading_indicator"
|
||||
static let resultsContainer = "identification_results_container"
|
||||
static let predictionRow = "identification_prediction_row"
|
||||
static let confidenceIndicator = "identification_confidence_indicator"
|
||||
static let retryButton = "identification_retry_button"
|
||||
static let returnToCameraButton = "identification_return_to_camera_button"
|
||||
static let saveToCollectionButton = "identification_save_to_collection_button"
|
||||
static let identifyAgainButton = "identification_identify_again_button"
|
||||
static let closeButton = "identification_close_button"
|
||||
static let errorView = "identification_error_view"
|
||||
}
|
||||
|
||||
// MARK: - Plant Detail
|
||||
|
||||
enum PlantDetail {
|
||||
static let detailView = "plant_detail_view"
|
||||
static let headerSection = "plant_detail_header_section"
|
||||
static let plantImage = "plant_detail_plant_image"
|
||||
static let plantName = "plant_detail_plant_name"
|
||||
static let scientificName = "plant_detail_scientific_name"
|
||||
static let familyName = "plant_detail_family_name"
|
||||
static let favoriteButton = "plant_detail_favorite_button"
|
||||
static let careSection = "plant_detail_care_section"
|
||||
static let wateringInfo = "plant_detail_watering_info"
|
||||
static let lightInfo = "plant_detail_light_info"
|
||||
static let humidityInfo = "plant_detail_humidity_info"
|
||||
static let tasksSection = "plant_detail_tasks_section"
|
||||
static let editButton = "plant_detail_edit_button"
|
||||
static let deleteButton = "plant_detail_delete_button"
|
||||
}
|
||||
|
||||
// MARK: - Care Schedule
|
||||
|
||||
enum CareSchedule {
|
||||
static let scheduleView = "care_schedule_view"
|
||||
static let todaySection = "care_schedule_today_section"
|
||||
static let upcomingSection = "care_schedule_upcoming_section"
|
||||
static let overdueSection = "care_schedule_overdue_section"
|
||||
static let taskRow = "care_schedule_task_row"
|
||||
static let completeAction = "care_schedule_complete_action"
|
||||
static let snoozeAction = "care_schedule_snooze_action"
|
||||
static let addTaskButton = "care_schedule_add_task_button"
|
||||
static let emptyStateView = "care_schedule_empty_state_view"
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
enum Settings {
|
||||
static let settingsView = "settings_view"
|
||||
static let notificationsToggle = "settings_notifications_toggle"
|
||||
static let appearanceSection = "settings_appearance_section"
|
||||
static let dataSection = "settings_data_section"
|
||||
static let clearCacheButton = "settings_clear_cache_button"
|
||||
static let versionInfo = "settings_version_info"
|
||||
}
|
||||
|
||||
// MARK: - Common
|
||||
|
||||
enum Common {
|
||||
static let loadingIndicator = "common_loading_indicator"
|
||||
static let errorView = "common_error_view"
|
||||
static let retryButton = "common_retry_button"
|
||||
static let closeButton = "common_close_button"
|
||||
static let backButton = "common_back_button"
|
||||
static let doneButton = "common_done_button"
|
||||
static let cancelButton = "common_cancel_button"
|
||||
}
|
||||
}
|
||||
78
PlantGuideUITests/Foundation/WaitHelpers.swift
Normal file
78
PlantGuideUITests/Foundation/WaitHelpers.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// WaitHelpers.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Centralized wait helpers for UI tests.
|
||||
// Replaces sleep() with deterministic, predicate-based waits.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
// MARK: - XCUIElement Wait Helpers
|
||||
|
||||
extension XCUIElement {
|
||||
|
||||
/// Waits until the element exists and is hittable.
|
||||
/// - Parameter timeout: Maximum seconds to wait (default 5).
|
||||
/// - Returns: `true` if the element became hittable within the timeout.
|
||||
@discardableResult
|
||||
func waitUntilHittable(timeout: TimeInterval = 5) -> Bool {
|
||||
let predicate = NSPredicate(format: "exists == true AND isHittable == true")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
/// Waits until the element disappears.
|
||||
/// - Parameter timeout: Maximum seconds to wait (default 5).
|
||||
/// - Returns: `true` if the element disappeared within the timeout.
|
||||
@discardableResult
|
||||
func waitUntilGone(timeout: TimeInterval = 5) -> Bool {
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
/// Waits until the element's value equals the expected string.
|
||||
/// - Parameters:
|
||||
/// - expectedValue: Target value.
|
||||
/// - timeout: Maximum seconds to wait (default 5).
|
||||
/// - Returns: `true` if the value matched within the timeout.
|
||||
@discardableResult
|
||||
func waitForValue(_ expectedValue: String, timeout: TimeInterval = 5) -> Bool {
|
||||
let predicate = NSPredicate(format: "value == %@", expectedValue)
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
/// Taps the element once it becomes hittable.
|
||||
/// - Parameter timeout: Maximum seconds to wait for hittable state.
|
||||
func tapWhenReady(timeout: TimeInterval = 5) {
|
||||
XCTAssertTrue(waitUntilHittable(timeout: timeout),
|
||||
"Element \(debugDescription) not hittable after \(timeout)s")
|
||||
tap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - XCUIApplication Wait Helpers
|
||||
|
||||
extension XCUIApplication {
|
||||
|
||||
/// Waits for the main tab bar to appear, indicating the app launched successfully.
|
||||
/// - Parameter timeout: Maximum seconds to wait (default 10).
|
||||
@discardableResult
|
||||
func waitForLaunch(timeout: TimeInterval = 10) -> Bool {
|
||||
tabBars.firstMatch.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
/// Waits for any element matching the identifier to appear.
|
||||
/// - Parameters:
|
||||
/// - identifier: The accessibility identifier.
|
||||
/// - timeout: Maximum seconds to wait (default 5).
|
||||
/// - Returns: The first matching element if found.
|
||||
@discardableResult
|
||||
func waitForElement(identifier: String, timeout: TimeInterval = 5) -> XCUIElement {
|
||||
let element = descendants(matching: .any)[identifier]
|
||||
_ = element.waitForExistence(timeout: timeout)
|
||||
return element
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user