Skip to main content
All articles

The Launch-and-Login Tax: Per-Class App Lifecycle in XCUITest

Ben Van AkenCo-Founder & CTO11 min read

In most UI-test suites, every test method launches the app, signs in and navigates to a starting point before it asserts anything. Part 7 showed what that costs in one production suite: a 39.1 second median setUp per test, of which 33.1 seconds is a typed login, 5.1 seconds is launch and idle, and 0.9 seconds is provisioning. That is 84 percent of setUp spent reaching a screen the previous test had already reached. This part explains how to launch once per class instead, what has to be true for that to be safe, and how to skip tests that do not belong on the current device before they pay for a launch at all.

How a per-class lifecycle works in XCTest

XCTest creates a new test-case instance for every method, so state that should survive across methods has to live in static storage. A per-class lifecycle keeps the launched XCUIApplication in a static dictionary keyed by class, and at each setUp decides whether to reuse it or launch fresh. Making it an opt-in flag, false by default, lets classes adopt it one at a time.

Swift
open class XCUITestCase: XCTestCase {
    /// Opt in per class, on a measured before/after. The base default stays false.
    open var usesPerClassAppLifecycle: Bool { false }

    private static var sharedApp: [ObjectIdentifier: XCUIApplication] = [:]
    private static var baselines: [ObjectIdentifier: WindowInventory] = [:]

    open override func setUpWithError() throws {
        continueAfterFailure = false        // unchanged: a failure never becomes a premise
        let key = ObjectIdentifier(type(of: self))
        if usesPerClassAppLifecycle,
           let app = Self.sharedApp[key], let baseline = Self.baselines[key],
           try resetToWorkspaceBaseline(app, baseline: baseline) {
            self.app = app                  // reuse: no launch, no login
            return
        }
        try launchAndLogIn()                // cold path, or the logged fallback
        Self.sharedApp[key] = app
        Self.baselines[key] = WindowInventory(app)
    }
}

The saving is a constant per launch removed. On one macOS class, XCTest's own timestamps at the end of setUp read 43.12 seconds for the cold first method and 17.55, 17.51, 17.83 and 17.52 seconds for the four methods that reused the app, a factor of 2.46. Across a rollout to 23 classes the constant came out at about 25.6 to 28.7 seconds per launch, depending on how it was derived. In total: 67 of 146 removable launches removed, summed test time from 6,252 to 4,259 seconds, minus 31.9 percent. On the final verification run every adopted class was faster, by between 23 and 66 percent.

Proving the app is reset between tests

Reusing the app is easy; proving that it is back where the next test expects it is the hard part. A natural first design is to wait for an identifier that exists only in the reset state. On a multi-window macOS app that does not work: some classes never reach the main workspace, and every workspace anchor is an app-wide query that resolves straight through an overlaying content window. The oracle is satisfied in exactly the dirty state it is supposed to reject.

An equality works better. Each class records the app's window inventory after its first launch. Between methods, the harness samples the current inventory twice, about 0.75 seconds apart, intersects the two samples, and requires the result to equal the recorded baseline. Extra windows are closed through the window's own close button in a bounded pass that names each one. When the equality cannot be restored, the class relaunches and records a harness event naming the windows that blocked it; three consecutive relaunches in one class fail the test with a message saying harness defect, not a product failure.

That fallback path needs a control of its own. One way to provide it: set the close budget to zero on a class that ends every method with extra windows open. In this suite, fallbacks one and two fired and were logged naming all three residue windows, both methods still passed, and fallback three failed the test in 5.31 seconds with the expected message. On the adopted classes the fallback fires about once in 58 reuse attempts.

Do

  • Keep continueAfterFailure = false. A shared app process does not make one test's failure the next test's premise.
  • Log every fallback relaunch with the reason. A silent relaunch hides the class that needs attention.
  • Keep one identity per class. If a class needs two logins, split it.

Don't

  • Don't close residue windows with Command-W. It closes whatever window is frontmost, which may be the one the next test needs.
  • Don't use a positive anchor as the reset oracle when app-wide queries can see through overlaying windows.
  • Don't widen the reset oracle for one class's modal.

Which classes must stay per-method

Not every class can share a process. In this suite six classes were adopted and then withdrawn on measurement, for five distinct reasons. Only the first can be found by reading the source; the others need a run.

  1. Per-instance state written inside a setup hook that the reuse path skips. A class that writes an implicitly unwrapped property in its provisioning hook and reads it in the test body sees nil from the second method on, and the runner crashes. This one is greppable, so sweep for it before adopting a class.
  2. An in-window modal the inventory cannot see. Partially predictable: look for methods that present sheets or alerts without dismissing them.
  3. Stale in-window model state: a conversation still open, a list selection still set. Found only by running.
  4. A launch-settled barrier, meaning a wait on the app's opening state that only a cold launch satisfies. The class goes green but slower (2.3 times slower in the measured case), and no pass/fail check sees it.
  5. Reliance on the relaunch to re-land a seed or clear a badge. If the app's seeding is latched per process, a per-class lifecycle makes it per class as well, and a class that mutates a shared fixture in one method and needs it re-established in the next must stay per-method.

Because three of the five have no static test, the base default should not be flipped to per-class. A flipped default adopts every class at once, including the ones that have never been run under it. Adoption one class at a time, each on a before/after pair read from the result bundle's outcomes, is slower but the only way to know each class is safe.

Do

  • Opt out with one named override, and a comment naming which of the five mechanisms applies, or stating that the class's subject is the launch itself (a first-launch gate, a pre-auth carousel).
  • Compare a newly adopted class on outcomes first and duration second. A launch-settled barrier is invisible to a duration-only comparison.

Don't

  • Don't opt a class out silently; treat a silent opt-out as a defect.
  • Don't judge eligibility from a duration threshold. Short pre-auth classes look eligible and are not.

Composing launch arguments

A per-class lifecycle, like most harness features, is carried by launch arguments, and any subclass that overrides the launch-argument property with a literal array drops everything the base adds. In this suite five of 79 macOS files did, which meant a flag could be added to the base, run green, and change nothing. The fix has two halves: add flags at the single place the arguments are assigned, and assert in setUp that the flag actually reached the launched app. A CI guard that fails any file assigning a literal array keeps it that way.

Swift
// Drops the tenant pin and every per-class flag, and runs green anyway.
override var uiTestingLaunchArguments: [String] { ["--uitesting", "-seed-documents"] }

// Composes with the base.
override var uiTestingLaunchArguments: [String] {
    super.uiTestingLaunchArguments + ["-seed-documents"]
}

Skipping wrong-device tests before the launch

The simulator lanes have a cheaper version of the same tax. A test that only applies to one device family often checks the width or the idiom inside its body and skips. By then setUpWithError has already launched the app and logged in, so the skip costs a full setUp. The fix is a lane declaration at the top of setUpWithError, before the call to super, keyed on the one signal that is readable before a launch: the user interface idiom.

Swift
final class CRMCompactListTests: CRMFlowBase {
    override func setUpWithError() throws {
        try requireLane(.iPhone)          // before super: no launch, no login on the wrong lane
        try super.setUpWithError()
    }
}

func requireLane(_ lane: Lane) throws {
    let idiom = UIDevice.current.userInterfaceIdiom
    if lane == .iPhone && idiom != .phone { throw XCTSkip("iPhone-only lane") }
    if lane == .iPad && idiom != .pad { throw XCTSkip("iPad-only lane") }
}

The effect is large wherever a suite runs the same target on more than one device. On the iPhone, moving 13 wrong-lane methods in a 22-method scope onto the seam took the scope's summed time from 1,157.3 to 828.3 seconds, minus 28.4 percent; the 13 moved methods went from 359.9 seconds to 0.7. On the iPad, 56 iPhone-only methods across 21 classes went from 1,496.2 seconds to 2.7, which was 12.46 percent of the whole iPad run.

Do

  • Put the declaration on the leaf class. A shared base class may serve both lanes; in this suite one base served three iPhone classes and one iPad class.
  • Guard the declaration with a CI check that asserts both its presence and its lane value. A check that only counts the old spelling cannot see a deleted seam or a wrong argument.
  • Once the seam is in place, delete the body-level width checks it made redundant, and claim no saving for that. A removal of dead code is accepted on an identical pass set; in this suite the iPad run came out 1.7 percent faster, which is inside noise.

Don't

  • Don't guard a lane with XCTAssertTrue(isCompact). The wrong-lane method becomes a failure after a full launch and login, instead of a skip before it.
  • Don't use width as a lane signal. An iPhone 17 Pro in landscape is 852 points wide, and a frame check needs a launched app anyway.

Orientation belongs to the class

iPad tests often rotate to landscape to see a split view. The simulator's orientation is global and outlives the runner, so a per-method rotation is paid on every method and leaks into the next class. Declare the orientation once in class setUp, guarded by idiom because class-scope methods run on the iPhone lane too, and restore portrait in class tearDown. In this suite that change took the number of manual portrait resets a run needed from eleven to one. Two iOS 27 details: a simulator reboot does not reset orientation (the fresh boot inherits the previous one), and each boot appends an entry to the orientation record, so a script that reads it back must take the last entry.

Checklist

  1. Measure the setUp breakdown first (part 7); adopt a per-class lifecycle only if launch and login dominate it.
  2. Keep the flag opt-in, adopt one class at a time, and compare outcomes before durations.
  3. Use a window-inventory equality as the reset oracle, close residue by the window's own control, and log every fallback relaunch.
  4. Sweep for per-instance state in skipped hooks before adopting; run to find the other four mechanisms.
  5. Compose launch arguments with super and assert they arrived.
  6. Declare device lanes before super.setUpWithError() on the leaf class; declare iPad orientation once per class.

Part 9 turns to the other place a suite pays on every green run: waits whose budget is spent in full because what they wait for never arrives.

Previously (Part 7): Measuring an XCUITest Suite Before You Speed It Up

Next up (Part 9): Negative Waits: The Seconds Every Green XCUITest Run Burns

Comments

Loading comments…

Does every UI test in your suite log in from scratch?

We restructure XCUITest harnesses for production SwiftUI apps so tests stop paying for launches they do not need, and we measure every saving. Book a call and let's look at your setUp.