feat: add XCUITest suite with 10 critical flow tests and QA test plan
Add comprehensive UI test infrastructure with Page Object pattern, accessibility identifiers, UI test mode (--ui-testing, --reset-state, --disable-animations), and 10 passing tests covering app launch, tab navigation, trip wizard, trip saving, settings, schedule, and accessibility at XXXL Dynamic Type. Also adds a 229-case QA test plan Excel workbook for manual QA handoff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
139
SportsTimeUITests/Framework/BaseUITestCase.swift
Normal file
139
SportsTimeUITests/Framework/BaseUITestCase.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// BaseUITestCase.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Base class for all UI tests. Provides launch configuration,
|
||||
// screenshot-on-failure, and centralized wait helpers.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
// MARK: - Base Test Case
|
||||
|
||||
class BaseUITestCase: XCTestCase {
|
||||
|
||||
/// The application under test. Configured in setUp.
|
||||
var app: XCUIApplication!
|
||||
|
||||
/// Standard timeout for element existence checks.
|
||||
static let defaultTimeout: TimeInterval = 15
|
||||
|
||||
/// Short timeout for elements expected to appear quickly.
|
||||
static let shortTimeout: TimeInterval = 5
|
||||
|
||||
/// Extended timeout for bootstrap / planning engine operations.
|
||||
static let longTimeout: TimeInterval = 30
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = [
|
||||
"--ui-testing",
|
||||
"--disable-animations",
|
||||
"--reset-state"
|
||||
]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Capture a screenshot on test failure for post-mortem debugging.
|
||||
if let failure = testRun, failure.failureCount > 0 {
|
||||
let screenshot = XCTAttachment(screenshot: app.screenshot())
|
||||
screenshot.name = "Failure-\(name)"
|
||||
screenshot.lifetime = .keepAlways
|
||||
add(screenshot)
|
||||
}
|
||||
app = nil
|
||||
}
|
||||
|
||||
// MARK: - Screenshot Helpers
|
||||
|
||||
/// Captures a named screenshot attached to the test report.
|
||||
func captureScreenshot(named name: String) {
|
||||
let screenshot = XCTAttachment(screenshot: app.screenshot())
|
||||
screenshot.name = name
|
||||
screenshot.lifetime = .keepAlways
|
||||
add(screenshot)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wait Helpers
|
||||
|
||||
extension XCUIElement {
|
||||
|
||||
/// Waits until the element exists, failing with a descriptive message.
|
||||
@discardableResult
|
||||
func waitForExistenceOrFail(
|
||||
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
|
||||
_ message: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> XCUIElement {
|
||||
let msg = message ?? "Expected \(self) to exist within \(timeout)s"
|
||||
XCTAssertTrue(waitForExistence(timeout: timeout), msg, file: file, line: line)
|
||||
return self
|
||||
}
|
||||
|
||||
/// Waits until the element exists AND is hittable.
|
||||
@discardableResult
|
||||
func waitUntilHittable(
|
||||
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
|
||||
_ message: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> XCUIElement {
|
||||
waitForExistenceOrFail(timeout: timeout, message, file: file, line: line)
|
||||
let predicate = NSPredicate(format: "isHittable == true")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
||||
let msg = message ?? "Expected \(self) to be hittable within \(timeout)s"
|
||||
XCTAssertEqual(result, .completed, msg, file: file, line: line)
|
||||
return self
|
||||
}
|
||||
|
||||
/// Waits until the element no longer exists.
|
||||
func waitForNonExistence(
|
||||
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
|
||||
_ message: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
||||
let msg = message ?? "Expected \(self) to disappear within \(timeout)s"
|
||||
XCTAssertEqual(result, .completed, msg, file: file, line: line)
|
||||
}
|
||||
|
||||
/// Scrolls a scroll view until this element is hittable, or times out.
|
||||
@discardableResult
|
||||
func scrollIntoView(
|
||||
in scrollView: XCUIElement,
|
||||
direction: ScrollDirection = .down,
|
||||
maxScrolls: Int = 10,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> XCUIElement {
|
||||
var scrollsRemaining = maxScrolls
|
||||
while !exists || !isHittable {
|
||||
guard scrollsRemaining > 0 else {
|
||||
XCTFail("Could not scroll \(self) into view after \(maxScrolls) scrolls",
|
||||
file: file, line: line)
|
||||
return self
|
||||
}
|
||||
switch direction {
|
||||
case .down:
|
||||
scrollView.swipeUp(velocity: .slow)
|
||||
case .up:
|
||||
scrollView.swipeDown(velocity: .slow)
|
||||
}
|
||||
scrollsRemaining -= 1
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
enum ScrollDirection {
|
||||
case up, down
|
||||
}
|
||||
410
SportsTimeUITests/Framework/Screens.swift
Normal file
410
SportsTimeUITests/Framework/Screens.swift
Normal file
@@ -0,0 +1,410 @@
|
||||
//
|
||||
// Screens.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Page Object / Screen Object layer.
|
||||
// Each struct wraps an XCUIApplication and exposes user-intent methods.
|
||||
// Tests read like: homeScreen.tapStartPlanning()
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
// MARK: - Home Screen
|
||||
|
||||
struct HomeScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: Elements
|
||||
|
||||
var startPlanningButton: XCUIElement {
|
||||
app.buttons["home.startPlanningButton"]
|
||||
}
|
||||
|
||||
var homeTab: XCUIElement {
|
||||
app.tabBars.buttons["Home"]
|
||||
}
|
||||
|
||||
var scheduleTab: XCUIElement {
|
||||
app.tabBars.buttons["Schedule"]
|
||||
}
|
||||
|
||||
var myTripsTab: XCUIElement {
|
||||
app.tabBars.buttons["My Trips"]
|
||||
}
|
||||
|
||||
var progressTab: XCUIElement {
|
||||
app.tabBars.buttons["Progress"]
|
||||
}
|
||||
|
||||
var settingsTab: XCUIElement {
|
||||
app.tabBars.buttons["Settings"]
|
||||
}
|
||||
|
||||
var adventureAwaitsText: XCUIElement {
|
||||
app.staticTexts["Adventure Awaits"]
|
||||
}
|
||||
|
||||
var createTripToolbarButton: XCUIElement {
|
||||
app.buttons["Create new trip"]
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
/// Waits for the home screen to fully load after bootstrap.
|
||||
@discardableResult
|
||||
func waitForLoad() -> HomeScreen {
|
||||
startPlanningButton.waitForExistenceOrFail(
|
||||
timeout: BaseUITestCase.longTimeout,
|
||||
"Home screen should load after bootstrap"
|
||||
)
|
||||
return self
|
||||
}
|
||||
|
||||
/// Taps "Start Planning" to open the Trip Wizard sheet.
|
||||
func tapStartPlanning() {
|
||||
startPlanningButton.waitUntilHittable().tap()
|
||||
}
|
||||
|
||||
/// Switches to a tab by tapping its tab bar button.
|
||||
func switchToTab(_ tab: XCUIElement) {
|
||||
tab.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
||||
}
|
||||
|
||||
// MARK: Assertions
|
||||
|
||||
/// Asserts the tab bar is visible with all 5 tabs.
|
||||
func assertTabBarVisible() {
|
||||
XCTAssertTrue(homeTab.exists, "Home tab should exist")
|
||||
XCTAssertTrue(scheduleTab.exists, "Schedule tab should exist")
|
||||
XCTAssertTrue(myTripsTab.exists, "My Trips tab should exist")
|
||||
XCTAssertTrue(progressTab.exists, "Progress tab should exist")
|
||||
XCTAssertTrue(settingsTab.exists, "Settings tab should exist")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Wizard Screen
|
||||
|
||||
struct TripWizardScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: Elements
|
||||
|
||||
var cancelButton: XCUIElement {
|
||||
app.buttons["Cancel"]
|
||||
}
|
||||
|
||||
var planTripButton: XCUIElement {
|
||||
app.buttons["wizard.planTripButton"]
|
||||
}
|
||||
|
||||
var navigationTitle: XCUIElement {
|
||||
app.navigationBars["Plan a Trip"]
|
||||
}
|
||||
|
||||
// Planning modes
|
||||
func planningModeButton(_ mode: String) -> XCUIElement {
|
||||
app.buttons["wizard.planningMode.\(mode)"]
|
||||
}
|
||||
|
||||
// Sports
|
||||
func sportButton(_ sport: String) -> XCUIElement {
|
||||
app.buttons["wizard.sports.\(sport)"]
|
||||
}
|
||||
|
||||
// Regions
|
||||
func regionButton(_ region: String) -> XCUIElement {
|
||||
app.buttons["wizard.regions.\(region)"]
|
||||
}
|
||||
|
||||
// Date picker
|
||||
var nextMonthButton: XCUIElement {
|
||||
app.buttons["wizard.dates.nextMonth"]
|
||||
}
|
||||
|
||||
var previousMonthButton: XCUIElement {
|
||||
app.buttons["wizard.dates.previousMonth"]
|
||||
}
|
||||
|
||||
var monthLabel: XCUIElement {
|
||||
app.staticTexts["wizard.dates.monthLabel"]
|
||||
}
|
||||
|
||||
func dayButton(_ dateString: String) -> XCUIElement {
|
||||
app.buttons["wizard.dates.day.\(dateString)"]
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
/// Waits for the wizard sheet to appear.
|
||||
@discardableResult
|
||||
func waitForLoad() -> TripWizardScreen {
|
||||
navigationTitle.waitForExistenceOrFail(
|
||||
timeout: BaseUITestCase.defaultTimeout,
|
||||
"Trip Wizard should appear"
|
||||
)
|
||||
return self
|
||||
}
|
||||
|
||||
/// Selects the "By Dates" planning mode and waits for steps to expand.
|
||||
func selectDateRangeMode() {
|
||||
let btn = planningModeButton("dateRange")
|
||||
btn.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
btn.tap()
|
||||
}
|
||||
|
||||
/// Navigates the calendar to a target month/year and selects start/end dates.
|
||||
func selectDateRange(
|
||||
targetMonth: String,
|
||||
targetYear: String,
|
||||
startDay: String,
|
||||
endDay: String
|
||||
) {
|
||||
// Navigate forward to the target month
|
||||
let target = "\(targetMonth) \(targetYear)"
|
||||
var attempts = 0
|
||||
// First ensure the month label is visible
|
||||
monthLabel.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
while !monthLabel.label.contains(target) && attempts < 18 {
|
||||
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
||||
attempts += 1
|
||||
}
|
||||
XCTAssertTrue(monthLabel.label.contains(target),
|
||||
"Should navigate to \(target)")
|
||||
|
||||
// Select start date — scroll calendar grid into view first
|
||||
let startBtn = dayButton(startDay)
|
||||
startBtn.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
startBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
||||
|
||||
// Select end date
|
||||
let endBtn = dayButton(endDay)
|
||||
endBtn.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
endBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
||||
}
|
||||
|
||||
/// Selects a sport (e.g., "mlb").
|
||||
func selectSport(_ sport: String) {
|
||||
let btn = sportButton(sport)
|
||||
btn.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
btn.tap()
|
||||
}
|
||||
|
||||
/// Selects a region (e.g., "central").
|
||||
func selectRegion(_ region: String) {
|
||||
let btn = regionButton(region)
|
||||
btn.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
btn.tap()
|
||||
}
|
||||
|
||||
/// Scrolls to and taps "Plan My Trip".
|
||||
func tapPlanTrip() {
|
||||
let btn = planTripButton
|
||||
btn.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
btn.waitUntilHittable().tap()
|
||||
}
|
||||
|
||||
/// Dismisses the wizard via the Cancel button.
|
||||
func tapCancel() {
|
||||
cancelButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Options Screen
|
||||
|
||||
struct TripOptionsScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: Elements
|
||||
|
||||
var sortDropdown: XCUIElement {
|
||||
app.buttons["tripOptions.sortDropdown"]
|
||||
}
|
||||
|
||||
func tripCard(_ index: Int) -> XCUIElement {
|
||||
app.buttons["tripOptions.trip.\(index)"]
|
||||
}
|
||||
|
||||
func sortOption(_ name: String) -> XCUIElement {
|
||||
app.buttons["tripOptions.sortOption.\(name)"]
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
/// Waits for planning results to load.
|
||||
@discardableResult
|
||||
func waitForLoad() -> TripOptionsScreen {
|
||||
sortDropdown.waitForExistenceOrFail(
|
||||
timeout: BaseUITestCase.longTimeout,
|
||||
"Trip Options should appear after planning completes"
|
||||
)
|
||||
return self
|
||||
}
|
||||
|
||||
/// Selects a trip option card by index.
|
||||
func selectTrip(at index: Int) {
|
||||
let card = tripCard(index)
|
||||
card.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
card.tap()
|
||||
}
|
||||
|
||||
/// Opens the sort dropdown and selects an option.
|
||||
func sort(by option: String) {
|
||||
sortDropdown.waitUntilHittable().tap()
|
||||
let optionBtn = sortOption(option)
|
||||
optionBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
||||
}
|
||||
|
||||
// MARK: Assertions
|
||||
|
||||
/// Asserts at least one trip option is visible.
|
||||
func assertHasResults() {
|
||||
XCTAssertTrue(tripCard(0).waitForExistence(timeout: BaseUITestCase.shortTimeout),
|
||||
"At least one trip option should exist")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Detail Screen
|
||||
|
||||
struct TripDetailScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: Elements
|
||||
|
||||
var favoriteButton: XCUIElement {
|
||||
app.buttons["tripDetail.favoriteButton"]
|
||||
}
|
||||
|
||||
var itineraryTitle: XCUIElement {
|
||||
app.staticTexts["Itinerary"]
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
/// Waits for the trip detail view to load.
|
||||
@discardableResult
|
||||
func waitForLoad() -> TripDetailScreen {
|
||||
favoriteButton.waitForExistenceOrFail(
|
||||
timeout: BaseUITestCase.defaultTimeout,
|
||||
"Trip Detail should load with favorite button"
|
||||
)
|
||||
return self
|
||||
}
|
||||
|
||||
/// Taps the favorite/save button.
|
||||
func tapFavorite() {
|
||||
favoriteButton.waitUntilHittable().tap()
|
||||
}
|
||||
|
||||
// MARK: Assertions
|
||||
|
||||
/// Asserts the itinerary section is visible.
|
||||
func assertItineraryVisible() {
|
||||
let title = itineraryTitle
|
||||
title.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
XCTAssertTrue(title.exists, "Itinerary section should be visible")
|
||||
}
|
||||
|
||||
/// Asserts the favorite button label matches the expected state.
|
||||
func assertSaveState(isSaved: Bool) {
|
||||
let expected = isSaved ? "Remove from favorites" : "Save to favorites"
|
||||
XCTAssertEqual(favoriteButton.label, expected,
|
||||
"Favorite button label should reflect saved state")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - My Trips Screen
|
||||
|
||||
struct MyTripsScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: Elements
|
||||
|
||||
var emptyState: XCUIElement {
|
||||
// VStack with accessibilityIdentifier can map to different element types on iOS 26;
|
||||
// use descendants(matching: .any) for a type-agnostic match.
|
||||
app.descendants(matching: .any)["myTrips.emptyState"]
|
||||
}
|
||||
|
||||
var savedTripsTitle: XCUIElement {
|
||||
app.staticTexts["Saved Trips"]
|
||||
}
|
||||
|
||||
// MARK: Assertions
|
||||
|
||||
func assertEmpty() {
|
||||
XCTAssertTrue(
|
||||
emptyState.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
|
||||
"Empty state should be visible when no trips saved"
|
||||
)
|
||||
}
|
||||
|
||||
func assertHasTrips() {
|
||||
XCTAssertFalse(emptyState.exists, "Empty state should not show when trips exist")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedule Screen
|
||||
|
||||
struct ScheduleScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: Elements
|
||||
|
||||
var searchField: XCUIElement {
|
||||
app.searchFields.firstMatch
|
||||
}
|
||||
|
||||
var filterButton: XCUIElement {
|
||||
// The filter menu button uses an accessibilityLabel
|
||||
app.buttons.matching(NSPredicate(
|
||||
format: "label CONTAINS 'Filter options'"
|
||||
)).firstMatch
|
||||
}
|
||||
|
||||
// MARK: Assertions
|
||||
|
||||
func assertLoaded() {
|
||||
// Schedule tab should show the filter button with "Filter options" label
|
||||
XCTAssertTrue(filterButton.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
|
||||
"Schedule filter button should appear")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Screen
|
||||
|
||||
struct SettingsScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// MARK: Elements
|
||||
|
||||
var versionLabel: XCUIElement {
|
||||
app.staticTexts["settings.versionLabel"]
|
||||
}
|
||||
|
||||
var subscriptionSection: XCUIElement {
|
||||
app.staticTexts["Subscription"]
|
||||
}
|
||||
|
||||
var privacySection: XCUIElement {
|
||||
app.staticTexts["Privacy"]
|
||||
}
|
||||
|
||||
var aboutSection: XCUIElement {
|
||||
app.staticTexts["About"]
|
||||
}
|
||||
|
||||
// MARK: Assertions
|
||||
|
||||
func assertLoaded() {
|
||||
XCTAssertTrue(subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
|
||||
"Settings should show Subscription section")
|
||||
}
|
||||
|
||||
func assertVersionDisplayed() {
|
||||
// SwiftUI List renders as UICollectionView on iOS 26, not UITableView
|
||||
versionLabel.scrollIntoView(in: app.collectionViews.firstMatch, direction: .down)
|
||||
XCTAssertTrue(versionLabel.exists, "App version should be displayed")
|
||||
XCTAssertFalse(versionLabel.label.isEmpty, "Version label should not be empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user