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

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