Enable parallel UI test execution via per-session data isolation

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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-03-24 15:04:55 -05:00
parent 8231750cff
commit 2ef1c1ec51
13 changed files with 128 additions and 145 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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).

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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,7 +20,7 @@ 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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)