From 2ef1c1ec5136a4f8c03b94c504e32c7704e69f59 Mon Sep 17 00:00:00 2001 From: Trey T Date: Tue, 24 Mar 2026 15:04:55 -0500 Subject: [PATCH] Enable parallel UI test execution via per-session data isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each test class now gets a unique session ID (UUID) passed to the app via UI_TEST_SESSION_ID environment variable. The app uses this to: - Route GroupUserDefaults to a session-specific UserDefaults suite, preventing tests from clobbering each other's AppStorage state - Create an in-memory SwiftData container instead of the shared on-disk App Group store, eliminating SQLite contention Refactored 8 test classes that bypassed BaseUITestCase.setUp() with custom launch args — they now use overridable `localeArguments` and `extraLaunchArguments` properties, keeping session ID injection centralized. Added `relaunchApp(resetState:bypassSubscription:)` to BaseUITestCase for tests that need mid-test relaunch with different subscription state. Includes a ParallelUITests.xctestplan with class-level parallelism enabled and random execution ordering. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../xcschemes/ParallelUITests.xctestplan | 26 +++++++++++ Shared/Persisence/SharedModelContainer.swift | 9 ++++ Shared/Random.swift | 19 ++++++++ Shared/UITestMode.swift | 11 ++++- Tests iOS/AccessibilityTextSizeTests.swift | 21 +-------- Tests iOS/DateLocaleTests.swift | 20 +-------- Tests iOS/Helpers/BaseUITestCase.swift | 44 ++++++++++++++++++- Tests iOS/HierarchyDumpTest.swift | 25 +++++------ Tests iOS/HighContrastTests.swift | 21 +-------- Tests iOS/LongTranslationTests.swift | 20 +-------- Tests iOS/ReduceMotionTests.swift | 21 +-------- Tests iOS/SpanishLocalizationTests.swift | 20 +-------- Tests iOS/TrialBannerTests.swift | 16 +------ 13 files changed, 128 insertions(+), 145 deletions(-) create mode 100644 Reflect.xcodeproj/xcshareddata/xcschemes/ParallelUITests.xctestplan diff --git a/Reflect.xcodeproj/xcshareddata/xcschemes/ParallelUITests.xctestplan b/Reflect.xcodeproj/xcshareddata/xcschemes/ParallelUITests.xctestplan new file mode 100644 index 0000000..f2ccdce --- /dev/null +++ b/Reflect.xcodeproj/xcshareddata/xcschemes/ParallelUITests.xctestplan @@ -0,0 +1,26 @@ +{ + "configurations" : [ + { + "id" : "9A1B2C3D-4E5F-6A7B-8C9D-0E1F2A3B4C5D", + "name" : "Parallel UI Tests", + "options" : { + "testExecutionOrdering" : "random" + } + } + ], + "defaultOptions" : { + "maximumTestExecutionTimeAllowance" : 180, + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Reflect.xcodeproj", + "identifier" : "Tests iOS", + "name" : "Tests iOS" + } + } + ], + "version" : 1 +} diff --git a/Shared/Persisence/SharedModelContainer.swift b/Shared/Persisence/SharedModelContainer.swift index fb466d5..0072b60 100644 --- a/Shared/Persisence/SharedModelContainer.swift +++ b/Shared/Persisence/SharedModelContainer.swift @@ -36,6 +36,15 @@ enum SharedModelContainer { /// - Returns: Configured ModelContainer /// - Throws: SharedModelContainerError if creation fails static func create(useCloudKit: Bool = true) throws -> ModelContainer { + // When UI testing, use in-memory storage for parallel test isolation. + // Each test process gets its own empty container — no shared on-disk state. + // Check ProcessInfo directly to avoid depending on UITestMode (not in widget targets). + if ProcessInfo.processInfo.arguments.contains("--ui-testing") { + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) + return try ModelContainer(for: schema, configurations: [config]) + } + let schema = Schema([MoodEntryModel.self]) let storeURL = try Self.storeURL diff --git a/Shared/Random.swift b/Shared/Random.swift index c80d87c..e7062c9 100644 --- a/Shared/Random.swift +++ b/Shared/Random.swift @@ -25,7 +25,26 @@ struct Constants { } struct GroupUserDefaults { + /// Whether the current process is a UI test session with an isolation ID. + /// Inlined from ProcessInfo to avoid depending on UITestMode (which isn't in widget targets). + private static var uiTestSessionID: String? { + guard ProcessInfo.processInfo.arguments.contains("--ui-testing") else { return nil } + return ProcessInfo.processInfo.environment["UI_TEST_SESSION_ID"] + } + + /// The suite name currently in use. Used by resetAppState() to clear the correct domain. + static var currentSuiteName: String { + if let sessionID = uiTestSessionID { + return "uitest.\(sessionID)" + } + return Constants.currentGroupShareId + } + static var groupDefaults: UserDefaults { + // When UI testing with a session ID, use a per-session suite for parallel isolation. + if let sessionID = uiTestSessionID { + return UserDefaults(suiteName: "uitest.\(sessionID)") ?? .standard + } #if DEBUG return UserDefaults(suiteName: Constants.groupShareIdDebug) ?? .standard #else diff --git a/Shared/UITestMode.swift b/Shared/UITestMode.swift index 1653689..1a30ee6 100644 --- a/Shared/UITestMode.swift +++ b/Shared/UITestMode.swift @@ -42,6 +42,12 @@ enum UITestMode { ProcessInfo.processInfo.arguments.contains("--expire-trial") } + /// Unique session ID for parallel test isolation. + /// Each test class gets its own session, ensuring no shared state between parallel test runners. + static var sessionID: String? { + ProcessInfo.processInfo.environment["UI_TEST_SESSION_ID"] + } + /// Seed fixture name if provided (via environment variable) static var seedFixture: String? { ProcessInfo.processInfo.environment["UI_TEST_FIXTURE"] @@ -93,8 +99,9 @@ enum UITestMode { @MainActor private static func resetAppState() { let defaults = GroupUserDefaults.groupDefaults - // Clear group user defaults using the suite domain name - defaults.removePersistentDomain(forName: Constants.currentGroupShareId) + // Clear group user defaults using the session-specific or shared suite domain name + let suiteName = GroupUserDefaults.currentSuiteName + defaults.removePersistentDomain(forName: suiteName) // Explicitly clear subscription cache keys that may survive removePersistentDomain // on app group suites (known reliability issue). diff --git a/Tests iOS/AccessibilityTextSizeTests.swift b/Tests iOS/AccessibilityTextSizeTests.swift index eb2c270..349a04a 100644 --- a/Tests iOS/AccessibilityTextSizeTests.swift +++ b/Tests iOS/AccessibilityTextSizeTests.swift @@ -10,25 +10,8 @@ import XCTest final class AccessibilityTextSizeTests: BaseUITestCase { override var seedFixture: String? { "single_mood" } override var bypassSubscription: Bool { true } - - override func setUp() { - // Do NOT call super — we need custom content size launch args - continueAfterFailure = false - - let application = XCUIApplication() - var args: [String] = [ - "--ui-testing", "--disable-animations", - "--reset-state", - "--bypass-subscription", - "--skip-onboarding", - "-AppleLanguages", "(en)", - "-AppleLocale", "en_US", - "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXL" - ] - application.launchArguments = args - application.launchEnvironment = ["UI_TEST_FIXTURE": "single_mood"] - application.launch() - app = application + override var extraLaunchArguments: [String] { + ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXL"] } /// TC-142: App launches and is navigable at largest accessibility text size. diff --git a/Tests iOS/DateLocaleTests.swift b/Tests iOS/DateLocaleTests.swift index 67728de..ab2f604 100644 --- a/Tests iOS/DateLocaleTests.swift +++ b/Tests iOS/DateLocaleTests.swift @@ -10,25 +10,7 @@ import XCTest final class DateLocaleTests: BaseUITestCase { override var seedFixture: String? { "week_of_moods" } override var bypassSubscription: Bool { true } - - override func setUp() { - // Do NOT call super — we need custom locale launch args - continueAfterFailure = false - - let application = XCUIApplication() - let args: [String] = [ - "--ui-testing", "--disable-animations", - "--reset-state", - "--bypass-subscription", - "--skip-onboarding", - "-AppleLanguages", "(de)", - "-AppleLocale", "de_DE" - ] - application.launchArguments = args - application.launchEnvironment = ["UI_TEST_FIXTURE": "week_of_moods"] - application.launch() - app = application - } + override var localeArguments: [String] { ["-AppleLanguages", "(de)", "-AppleLocale", "de_DE"] } /// TC-139: German locale displays German month/weekday names. func testGermanLocale_DateFormattingMatchesLocale() { diff --git a/Tests iOS/Helpers/BaseUITestCase.swift b/Tests iOS/Helpers/BaseUITestCase.swift index c3f8a6e..2c00f80 100644 --- a/Tests iOS/Helpers/BaseUITestCase.swift +++ b/Tests iOS/Helpers/BaseUITestCase.swift @@ -3,7 +3,8 @@ // Tests iOS // // Base class for all UI tests. Handles launch arguments, -// state reset, and screenshot capture on failure. +// state reset, screenshot capture on failure, and parallel +// test isolation via per-session data sandboxing. // import XCTest @@ -12,6 +13,13 @@ class BaseUITestCase: XCTestCase { var app: XCUIApplication! + // MARK: - Parallel Test Isolation + + /// Unique session ID for this test class instance. + /// Passed to the app via environment so each parallel runner gets + /// its own UserDefaults suite and in-memory SwiftData container. + private(set) var testSessionID: String = UUID().uuidString + // MARK: - Configuration (override in subclasses) /// Fixture to seed. Override to use a specific data set. @@ -26,6 +34,15 @@ class BaseUITestCase: XCTestCase { /// Whether to force the trial to be expired. Default: false. var expireTrial: Bool { false } + /// Override to change the test locale/language. + /// Default: English (US). Locale tests override this instead of setUp(). + var localeArguments: [String] { ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] } + + /// Extra launch arguments for tests needing special settings + /// (accessibility sizes, reduce motion, high contrast, etc.). + /// Override in subclasses instead of overriding setUp(). + var extraLaunchArguments: [String] { [] } + // MARK: - Lifecycle override func setUp() { @@ -46,7 +63,8 @@ class BaseUITestCase: XCTestCase { // MARK: - Launch Configuration private func buildLaunchArguments(resetState: Bool) -> [String] { - var args = ["--ui-testing", "--disable-animations", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + var args = ["--ui-testing", "--disable-animations"] + args.append(contentsOf: localeArguments) if resetState { args.append("--reset-state") } @@ -59,11 +77,13 @@ class BaseUITestCase: XCTestCase { if expireTrial { args.append("--expire-trial") } + args.append(contentsOf: extraLaunchArguments) return args } private func buildLaunchEnvironment() -> [String: String] { var env = [String: String]() + env["UI_TEST_SESSION_ID"] = testSessionID if let fixture = seedFixture { env["UI_TEST_FIXTURE"] = fixture } @@ -90,6 +110,26 @@ class BaseUITestCase: XCTestCase { return application } + /// Relaunch the app with custom bypass setting, preserving the session ID. + /// Use when a test needs to toggle subscription bypass mid-test. + @discardableResult + func relaunchApp(resetState: Bool, bypassSubscription overrideBypass: Bool) -> XCUIApplication { + app.terminate() + let application = XCUIApplication() + var args = ["--ui-testing", "--disable-animations"] + args.append(contentsOf: localeArguments) + if resetState { args.append("--reset-state") } + if overrideBypass { args.append("--bypass-subscription") } + if skipOnboarding { args.append("--skip-onboarding") } + if expireTrial { args.append("--expire-trial") } + args.append(contentsOf: extraLaunchArguments) + application.launchArguments = args + application.launchEnvironment = buildLaunchEnvironment() + application.launch() + app = application + return application + } + @discardableResult func relaunchPreservingState() -> XCUIApplication { app.terminate() diff --git a/Tests iOS/HierarchyDumpTest.swift b/Tests iOS/HierarchyDumpTest.swift index fc11984..e764048 100644 --- a/Tests iOS/HierarchyDumpTest.swift +++ b/Tests iOS/HierarchyDumpTest.swift @@ -1,19 +1,18 @@ import XCTest -class HierarchyDumpTest: XCTestCase { +class HierarchyDumpTest: BaseUITestCase { + override var seedFixture: String? { nil } + func testDumpAccessibilityTree() { - let app = XCUIApplication() - app.launchArguments = ["--ui-testing", "--reset-state", "--disable-animations", "--bypass-subscription", "--skip-onboarding"] - app.launch() sleep(3) print("\n=== ELEMENT QUERIES ===") - print("otherElements[mood_header]: \(app.otherElements[\"mood_header\"].exists)") - print("descendants[mood_header]: \(app.descendants(matching: .any)[\"mood_header\"].firstMatch.exists)") - print("groups[mood_header]: \(app.groups[\"mood_header\"].exists)") - print("scrollViews[mood_header]: \(app.scrollViews[\"mood_header\"].exists)") - print("staticTexts[mood_header]: \(app.staticTexts[\"mood_header\"].exists)") - print("buttons[mood_button_great]: \(app.buttons[\"mood_button_great\"].exists)") + print("otherElements[mood_header]: \(app.otherElements["mood_header"].exists)") + print("descendants[mood_header]: \(app.descendants(matching: .any)["mood_header"].firstMatch.exists)") + print("groups[mood_header]: \(app.groups["mood_header"].exists)") + print("scrollViews[mood_header]: \(app.scrollViews["mood_header"].exists)") + print("staticTexts[mood_header]: \(app.staticTexts["mood_header"].exists)") + print("buttons[mood_button_great]: \(app.buttons["mood_button_great"].exists)") print("tabBars count: \(app.tabBars.count)") if app.tabBars.count > 0 { let tb = app.tabBars.firstMatch @@ -21,15 +20,15 @@ class HierarchyDumpTest: XCTestCase { print(" tab button: \(b.identifier) label=\(b.label)") } } - print("otherElements[settings_header]: \(app.otherElements[\"settings_header\"].exists)") - + print("otherElements[settings_header]: \(app.otherElements["settings_header"].exists)") + print("\n=== HIERARCHY (first 200 lines) ===") let desc = app.debugDescription let lines = desc.components(separatedBy: "\n") for (i, line) in lines.prefix(200).enumerated() { print("\(i): \(line)") } - + XCTAssertTrue(true) // always pass } } diff --git a/Tests iOS/HighContrastTests.swift b/Tests iOS/HighContrastTests.swift index 0bd23bd..407a653 100644 --- a/Tests iOS/HighContrastTests.swift +++ b/Tests iOS/HighContrastTests.swift @@ -10,25 +10,8 @@ import XCTest final class HighContrastTests: BaseUITestCase { override var seedFixture: String? { "single_mood" } override var bypassSubscription: Bool { true } - - override func setUp() { - // Do NOT call super — we need custom accessibility launch args - continueAfterFailure = false - - let application = XCUIApplication() - let args: [String] = [ - "--ui-testing", "--disable-animations", - "--reset-state", - "--bypass-subscription", - "--skip-onboarding", - "-AppleLanguages", "(en)", - "-AppleLocale", "en_US", - "-UIAccessibilityDarkerSystemColorsEnabled", "YES" - ] - application.launchArguments = args - application.launchEnvironment = ["UI_TEST_FIXTURE": "single_mood"] - application.launch() - app = application + override var extraLaunchArguments: [String] { + ["-UIAccessibilityDarkerSystemColorsEnabled", "YES"] } /// TC-144: App is navigable with High Contrast mode enabled. diff --git a/Tests iOS/LongTranslationTests.swift b/Tests iOS/LongTranslationTests.swift index 311976a..df3b35b 100644 --- a/Tests iOS/LongTranslationTests.swift +++ b/Tests iOS/LongTranslationTests.swift @@ -10,25 +10,7 @@ import XCTest final class LongTranslationTests: BaseUITestCase { override var seedFixture: String? { "single_mood" } override var bypassSubscription: Bool { true } - - override func setUp() { - // Do NOT call super — we need German locale (known for long compound words) - continueAfterFailure = false - - let application = XCUIApplication() - let args: [String] = [ - "--ui-testing", "--disable-animations", - "--reset-state", - "--bypass-subscription", - "--skip-onboarding", - "-AppleLanguages", "(de)", - "-AppleLocale", "de_DE" - ] - application.launchArguments = args - application.launchEnvironment = ["UI_TEST_FIXTURE": "single_mood"] - application.launch() - app = application - } + override var localeArguments: [String] { ["-AppleLanguages", "(de)", "-AppleLocale", "de_DE"] } /// TC-138: German locale with long compound words renders without crashes. /// Navigates through all tabs to ensure no layout truncation causes issues. diff --git a/Tests iOS/ReduceMotionTests.swift b/Tests iOS/ReduceMotionTests.swift index ee69170..e83f695 100644 --- a/Tests iOS/ReduceMotionTests.swift +++ b/Tests iOS/ReduceMotionTests.swift @@ -10,25 +10,8 @@ import XCTest final class ReduceMotionTests: BaseUITestCase { override var seedFixture: String? { "single_mood" } override var bypassSubscription: Bool { true } - - override func setUp() { - // Do NOT call super — we need custom accessibility launch args - continueAfterFailure = false - - let application = XCUIApplication() - let args: [String] = [ - "--ui-testing", "--disable-animations", - "--reset-state", - "--bypass-subscription", - "--skip-onboarding", - "-AppleLanguages", "(en)", - "-AppleLocale", "en_US", - "-UIReduceMotionPreference", "YES" - ] - application.launchArguments = args - application.launchEnvironment = ["UI_TEST_FIXTURE": "single_mood"] - application.launch() - app = application + override var extraLaunchArguments: [String] { + ["-UIReduceMotionPreference", "YES"] } /// TC-143: App is navigable with Reduce Motion enabled. diff --git a/Tests iOS/SpanishLocalizationTests.swift b/Tests iOS/SpanishLocalizationTests.swift index ed352b7..95e2764 100644 --- a/Tests iOS/SpanishLocalizationTests.swift +++ b/Tests iOS/SpanishLocalizationTests.swift @@ -10,25 +10,7 @@ import XCTest final class SpanishLocalizationTests: BaseUITestCase { override var seedFixture: String? { "week_of_moods" } override var bypassSubscription: Bool { true } - - override func setUp() { - // Do NOT call super — we need custom language launch args - continueAfterFailure = false - - let application = XCUIApplication() - let args: [String] = [ - "--ui-testing", "--disable-animations", - "--reset-state", - "--bypass-subscription", - "--skip-onboarding", - "-AppleLanguages", "(es)", - "-AppleLocale", "es_ES" - ] - application.launchArguments = args - application.launchEnvironment = ["UI_TEST_FIXTURE": "week_of_moods"] - application.launch() - app = application - } + override var localeArguments: [String] { ["-AppleLanguages", "(es)", "-AppleLocale", "es_ES"] } /// TC-137: Key Spanish strings appear when launched in Spanish locale. func testSpanishLocale_DisplaysSpanishStrings() { diff --git a/Tests iOS/TrialBannerTests.swift b/Tests iOS/TrialBannerTests.swift index 89c5045..b903ba1 100644 --- a/Tests iOS/TrialBannerTests.swift +++ b/Tests iOS/TrialBannerTests.swift @@ -20,20 +20,8 @@ final class TrialBannerTests: BaseUITestCase { settingsScreen.assertVisible() // With default settings (bypassSubscription = true), the banner is hidden. - // We need to launch without bypass to see the banner. - // Re-launch with bypass disabled. - app.terminate() - - let freshApp = XCUIApplication() - var args = ["--ui-testing", "--reset-state", "--disable-animations", "--skip-onboarding", - "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] - // Do NOT add --bypass-subscription - freshApp.launchArguments = args - if let fixture = seedFixture { - freshApp.launchEnvironment = ["UI_TEST_FIXTURE": fixture] - } - freshApp.launch() - app = freshApp + // Re-launch without bypass to see the banner. + relaunchApp(resetState: true, bypassSubscription: false) // Navigate to Settings let freshTabBar = TabBarScreen(app: app)