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:
48
PlantGuideUITests/Screens/CameraScreen.swift
Normal file
48
PlantGuideUITests/Screens/CameraScreen.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// CameraScreen.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Screen object for the Camera tab.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
struct CameraScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var captureButton: XCUIElement {
|
||||
app.buttons[UITestID.Camera.captureButton]
|
||||
}
|
||||
|
||||
var permissionDeniedView: XCUIElement {
|
||||
app.otherElements[UITestID.Camera.permissionDeniedView]
|
||||
}
|
||||
|
||||
var openSettingsButton: XCUIElement {
|
||||
app.buttons[UITestID.Camera.openSettingsButton]
|
||||
}
|
||||
|
||||
var previewView: XCUIElement {
|
||||
app.otherElements[UITestID.Camera.previewView]
|
||||
}
|
||||
|
||||
// MARK: - State Checks
|
||||
|
||||
/// Returns `true` if any valid camera state is visible (authorized, denied, or requesting).
|
||||
func hasValidState(timeout: TimeInterval = 5) -> Bool {
|
||||
captureButton.waitForExistence(timeout: timeout)
|
||||
|| permissionDeniedView.waitForExistence(timeout: 2)
|
||||
// Fallback: look for any text that hints at camera permission
|
||||
|| app.staticTexts.matching(
|
||||
NSPredicate(format: "label CONTAINS[c] 'camera'")
|
||||
).firstMatch.waitForExistence(timeout: 2)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func tapCapture() {
|
||||
captureButton.tapWhenReady()
|
||||
}
|
||||
}
|
||||
53
PlantGuideUITests/Screens/CollectionScreen.swift
Normal file
53
PlantGuideUITests/Screens/CollectionScreen.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// CollectionScreen.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Screen object for the Collection tab.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
struct CollectionScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var navigationBar: XCUIElement { app.navigationBars["My Plants"] }
|
||||
|
||||
var searchField: XCUIElement { app.searchFields.firstMatch }
|
||||
|
||||
var viewModeToggle: XCUIElement {
|
||||
app.buttons[UITestID.Collection.viewModeToggle]
|
||||
}
|
||||
|
||||
var filterButton: XCUIElement {
|
||||
app.buttons[UITestID.Collection.filterButton]
|
||||
}
|
||||
|
||||
var emptyStateView: XCUIElement {
|
||||
app.otherElements[UITestID.Collection.emptyStateView]
|
||||
}
|
||||
|
||||
var scrollView: XCUIElement { app.scrollViews.firstMatch }
|
||||
|
||||
var tableView: XCUIElement { app.tables.firstMatch }
|
||||
|
||||
// MARK: - State Checks
|
||||
|
||||
@discardableResult
|
||||
func waitForLoad(timeout: TimeInterval = 10) -> Bool {
|
||||
navigationBar.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
var hasPlants: Bool {
|
||||
scrollView.exists || tableView.exists
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func search(_ text: String) {
|
||||
XCTAssertTrue(searchField.waitForExistence(timeout: 5), "Search field missing")
|
||||
searchField.tap()
|
||||
searchField.typeText(text)
|
||||
}
|
||||
}
|
||||
61
PlantGuideUITests/Screens/PlantDetailScreen.swift
Normal file
61
PlantGuideUITests/Screens/PlantDetailScreen.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// PlantDetailScreen.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Screen object for the Plant Detail view.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
struct PlantDetailScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var detailView: XCUIElement {
|
||||
app.otherElements[UITestID.PlantDetail.detailView]
|
||||
}
|
||||
|
||||
var plantName: XCUIElement {
|
||||
app.staticTexts[UITestID.PlantDetail.plantName]
|
||||
}
|
||||
|
||||
var favoriteButton: XCUIElement {
|
||||
app.buttons[UITestID.PlantDetail.favoriteButton]
|
||||
}
|
||||
|
||||
var editButton: XCUIElement {
|
||||
app.buttons[UITestID.PlantDetail.editButton]
|
||||
}
|
||||
|
||||
var deleteButton: XCUIElement {
|
||||
app.buttons[UITestID.PlantDetail.deleteButton]
|
||||
}
|
||||
|
||||
var careSection: XCUIElement {
|
||||
app.otherElements[UITestID.PlantDetail.careSection]
|
||||
}
|
||||
|
||||
var tasksSection: XCUIElement {
|
||||
app.otherElements[UITestID.PlantDetail.tasksSection]
|
||||
}
|
||||
|
||||
/// The back button in navigation bar (leads back to Collection).
|
||||
var backButton: XCUIElement {
|
||||
app.navigationBars.buttons.firstMatch
|
||||
}
|
||||
|
||||
// MARK: - State Checks
|
||||
|
||||
@discardableResult
|
||||
func waitForLoad(timeout: TimeInterval = 5) -> Bool {
|
||||
// Wait for any navigation bar to appear (title is dynamic plant name)
|
||||
app.navigationBars.firstMatch.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func tapBack() {
|
||||
backButton.tapWhenReady()
|
||||
}
|
||||
}
|
||||
44
PlantGuideUITests/Screens/SettingsScreen.swift
Normal file
44
PlantGuideUITests/Screens/SettingsScreen.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// SettingsScreen.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Screen object for the Settings tab.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
struct SettingsScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var navigationBar: XCUIElement { app.navigationBars["Settings"] }
|
||||
|
||||
var notificationsToggle: XCUIElement {
|
||||
app.switches[UITestID.Settings.notificationsToggle]
|
||||
}
|
||||
|
||||
var clearCacheButton: XCUIElement {
|
||||
app.buttons[UITestID.Settings.clearCacheButton]
|
||||
}
|
||||
|
||||
var versionInfo: XCUIElement {
|
||||
app.staticTexts[UITestID.Settings.versionInfo]
|
||||
}
|
||||
|
||||
/// The settings form container — SwiftUI Form renders as a table or collection view.
|
||||
var formContainer: XCUIElement {
|
||||
if app.tables.firstMatch.waitForExistence(timeout: 3) {
|
||||
return app.tables.firstMatch
|
||||
} else {
|
||||
return app.collectionViews.firstMatch
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State Checks
|
||||
|
||||
@discardableResult
|
||||
func waitForLoad(timeout: TimeInterval = 10) -> Bool {
|
||||
navigationBar.waitForExistence(timeout: timeout)
|
||||
}
|
||||
}
|
||||
77
PlantGuideUITests/Screens/TabBarScreen.swift
Normal file
77
PlantGuideUITests/Screens/TabBarScreen.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// TabBarScreen.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Screen object for the main tab bar.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
struct TabBarScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var tabBar: XCUIElement { app.tabBars.firstMatch }
|
||||
var cameraTab: XCUIElement { tabBar.buttons[UITestID.TabBar.camera] }
|
||||
var collectionTab: XCUIElement { tabBar.buttons[UITestID.TabBar.collection] }
|
||||
var todayTab: XCUIElement { tabBar.buttons[UITestID.TabBar.today] }
|
||||
var settingsTab: XCUIElement { tabBar.buttons[UITestID.TabBar.settings] }
|
||||
|
||||
var allTabLabels: [String] {
|
||||
[UITestID.TabBar.camera,
|
||||
UITestID.TabBar.collection,
|
||||
UITestID.TabBar.today,
|
||||
UITestID.TabBar.settings]
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Taps a tab button using existence-based wait (not hittable — tab bar buttons
|
||||
/// report isHittable == false in iOS 26 simulator despite being tappable).
|
||||
private func tapTab(_ tab: XCUIElement) {
|
||||
XCTAssertTrue(tab.waitForExistence(timeout: 10),
|
||||
"Tab '\(tab.label)' not found within 10s")
|
||||
tab.tap()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func tapCamera() -> CameraScreen {
|
||||
tapTab(cameraTab)
|
||||
return CameraScreen(app: app)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func tapCollection() -> CollectionScreen {
|
||||
tapTab(collectionTab)
|
||||
return CollectionScreen(app: app)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func tapToday() -> TodayScreen {
|
||||
tapTab(todayTab)
|
||||
return TodayScreen(app: app)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func tapSettings() -> SettingsScreen {
|
||||
tapTab(settingsTab)
|
||||
return SettingsScreen(app: app)
|
||||
}
|
||||
|
||||
// MARK: - Assertions
|
||||
|
||||
func assertAllTabsExist() {
|
||||
XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Tab bar missing")
|
||||
for label in allTabLabels {
|
||||
XCTAssertTrue(tabBar.buttons[label].waitForExistence(timeout: 5),
|
||||
"Tab '\(label)' missing")
|
||||
}
|
||||
}
|
||||
|
||||
func assertSelected(_ label: String) {
|
||||
let tab = tabBar.buttons[label]
|
||||
XCTAssertTrue(tab.waitForExistence(timeout: 5), "Tab '\(label)' not found")
|
||||
XCTAssertTrue(tab.isSelected, "Tab '\(label)' not selected")
|
||||
}
|
||||
}
|
||||
40
PlantGuideUITests/Screens/TodayScreen.swift
Normal file
40
PlantGuideUITests/Screens/TodayScreen.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// TodayScreen.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Screen object for the Today tab (care tasks dashboard).
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
struct TodayScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
/// The Today view uses a dynamic greeting as navigation title.
|
||||
/// We check for the nav bar existence rather than a fixed title.
|
||||
var navigationBar: XCUIElement {
|
||||
app.navigationBars.firstMatch
|
||||
}
|
||||
|
||||
var todaySection: XCUIElement {
|
||||
app.otherElements[UITestID.CareSchedule.todaySection]
|
||||
}
|
||||
|
||||
var overdueSection: XCUIElement {
|
||||
app.otherElements[UITestID.CareSchedule.overdueSection]
|
||||
}
|
||||
|
||||
var emptyStateView: XCUIElement {
|
||||
app.otherElements[UITestID.CareSchedule.emptyStateView]
|
||||
}
|
||||
|
||||
// MARK: - State Checks
|
||||
|
||||
@discardableResult
|
||||
func waitForLoad(timeout: TimeInterval = 10) -> Bool {
|
||||
// The Today view has a dynamic nav title (greeting) so we just wait for any nav bar
|
||||
navigationBar.waitForExistence(timeout: timeout)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user