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>
147 lines
4.9 KiB
Swift
147 lines
4.9 KiB
Swift
//
|
|
// BaseUITestCase.swift
|
|
// Tests iOS
|
|
//
|
|
// Base class for all UI tests. Handles launch arguments,
|
|
// state reset, screenshot capture on failure, and parallel
|
|
// test isolation via per-session data sandboxing.
|
|
//
|
|
|
|
import XCTest
|
|
|
|
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.
|
|
var seedFixture: String? { nil }
|
|
|
|
/// Whether to bypass the subscription paywall. Default: true.
|
|
var bypassSubscription: Bool { true }
|
|
|
|
/// Whether to skip onboarding. Default: true.
|
|
var skipOnboarding: Bool { true }
|
|
|
|
/// 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() {
|
|
super.setUp()
|
|
continueAfterFailure = false
|
|
|
|
app = launchApp(resetState: true)
|
|
}
|
|
|
|
override func tearDown() {
|
|
if let failure = testRun?.failureCount, failure > 0 {
|
|
captureScreenshot(name: "FAILURE-\(name)")
|
|
}
|
|
app = nil
|
|
super.tearDown()
|
|
}
|
|
|
|
// MARK: - Launch Configuration
|
|
|
|
private func buildLaunchArguments(resetState: Bool) -> [String] {
|
|
var args = ["--ui-testing", "--disable-animations"]
|
|
args.append(contentsOf: localeArguments)
|
|
if resetState {
|
|
args.append("--reset-state")
|
|
}
|
|
if bypassSubscription {
|
|
args.append("--bypass-subscription")
|
|
}
|
|
if skipOnboarding {
|
|
args.append("--skip-onboarding")
|
|
}
|
|
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
|
|
}
|
|
return env
|
|
}
|
|
|
|
// MARK: - Screenshots
|
|
|
|
func captureScreenshot(name: String) {
|
|
let screenshot = XCTAttachment(screenshot: app.screenshot())
|
|
screenshot.name = name
|
|
screenshot.lifetime = .keepAlways
|
|
add(screenshot)
|
|
}
|
|
|
|
// MARK: - Shared Test Utilities
|
|
|
|
@discardableResult
|
|
func launchApp(resetState: Bool) -> XCUIApplication {
|
|
let application = XCUIApplication()
|
|
application.launchArguments = buildLaunchArguments(resetState: resetState)
|
|
application.launchEnvironment = buildLaunchEnvironment()
|
|
application.launch()
|
|
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()
|
|
let relaunched = launchApp(resetState: false)
|
|
app = relaunched
|
|
return relaunched
|
|
}
|
|
|
|
func assertDayContentVisible(timeout: TimeInterval = 8, file: StaticString = #file, line: UInt = #line) {
|
|
let hasEntry = app.firstEntryRow.waitForExistence(timeout: timeout)
|
|
let hasMoodHeader = app.element(UITestID.Day.moodHeader).waitForExistence(timeout: 2)
|
|
XCTAssertTrue(hasEntry || hasMoodHeader, "Day view should show entry list or mood header", file: file, line: line)
|
|
}
|
|
}
|