How to fix flaky XCUITests
A flaky test is one that passes and fails on the same code. It costs you twice: a red build you rerun, and a team that stops believing a red build. UI tests flake more than unit tests because they race a real app, a real run loop and animations. Most of the flakes are one of five causes, and each has a fix.
First, make the flake happen on demand
You can’t fix what you can’t repeat. Run the one test many times on the machine that flakes, and stop at the first failure:
xcodebuild test -scheme YourApp -only-testing:YourAppUITests/ CheckoutTests/ testPay \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ -run-tests-until-failure -test-iterations 50-test-iterations sets the number of runs and -run-tests-until-failure stops at the first red one. Keep the result bundle: its screenshots and the activity log show what the screen looked like at the failure. Now match the failure to a cause.
1. A fixed sleep
sleep(2) is a guess about how fast a machine is. On a busy CI Mac the guess loses. Wait for the thing you need, with a timeout that you pick for the slowest machine:
let pay = app.buttons["pay_button"]XCTAssertTrue(pay.waitForExistence(timeout: 10))pay.tap()waitForExistence returns as soon as the element exists, so a fast run stays fast. Existing isn’t the same as ready to tap: for a button that appears disabled first, wait on a predicate with XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true"), object: pay).
2. A query that matches by text
app.buttons["Pay"] breaks when a designer renames the button, when you localize it, or when two buttons say “Pay”. Give the control an identifier and query that:
Button("Pay") { pay() } .accessibilityIdentifier("pay_button")Identifiers don’t change with copy, and they never appear to VoiceOver users.
3. Animations and transitions
A tap on a view that is still sliding in can land on the wrong frame. XCUITest waits for the app to be idle, but a looping animation or a running timer keeps the app from ever being idle, and tests then time out or tap a moving target. Turn animations off for UI tests with a launch argument your app reads:
// In the testapp.launchArguments += ["-UITests", "YES"]// In the appif ProcessInfo.processInfo.arguments.contains("-UITests") { UIView.setAnimationsEnabled(false)}4. State left over from another test
A test that passes alone and fails in the suite shares state with a neighbour: a signed-in user, a saved draft, a toggled setting. Start every test from the same state. Launch with arguments that reset the app, and seed the data the test needs through launch arguments or environment variables, not through the screens another test uses:
app.launchArguments += ["-ResetState", "YES"]app.launchEnvironment["SEED_TASKS"] = "3"5. A real network call
A test that calls your real backend passes when the server is fast and fails when it isn’t. Serve canned responses from a local stub in UI-test mode, or point the app at a staging server that you control and that you reset before the run.
Measure the fix
Run the repeat command again after each fix. Fifty clean runs provide more evidence than one. If each run has an independent 10 percent chance of failure, the test still passes 10 in a row about 35 percent of the time. Once it holds, run the suite in CI on every pull request, where a flake shows up first.
Mobster measures flakiness for you
mobster test treats flakiness as a result. A check that fails and then passes on a retry is reported as flaky, not green:
✓ passed Marking a task done lowers the count on iPhone 17 Pro, iOS 26.4~ flaky A new user finishes onboarding and sees the task list on iPhone 17 Pro, iOS 26.4 (2 attempts) 1 of 1 expectations failed- A failed run is tried again once, from a fresh app (
--retries N, 0 to 5). The retry resets the app first. State outside that reset can still affect the result. mobster test --repeat 5 .mobster/runs each check K times and gives a pass rate. A check passes only when all K runs pass.checks/ onboarding.yaml --strictmakes a flaky check fail the run, so a retry can’t hide it.- A check you know is flaky goes in
.mobster/with a reason. It still runs and is reported (JUnitquarantine.yaml <skipped>), and it never changes the exit code:
- check: restore-purchases reason: Flaky on iOS 26.4, a looping animationMobster waits for each expectation, with a 5-second timeout by default (--assert-timeout S). A check can match id, label or value from the accessibility tree.
A Mobster check can still flake for the causes above that live in your app: a real network call, a timer that keeps animating, state a previous test left on a phone. The report includes the failed expectation and frame to help you investigate. It does not fix the app’s race. On a simulator each run also keeps the app’s log and, on a crash, the crash report, so the failing run explains itself. When the flake is a crash, Reproduce an iOS crash turns it into a check.
Download Mobster for Mac to run mobster test --repeat on your own suite: free (AI runs on a supported AI plan, an API key or a model on your Mac).
The CLI reference lists every mobster test flag, and Checks explains the check file. Maestro vs XCUITest vs Mobster says where each tool fits.
Questions
Why do XCUITests pass locally and fail in CI?
CI machines can be busier or slower than your Mac. Fixed sleeps and unwaited screen changes can fail under that load. A clean simulator can also expose tests that depend on state from an earlier run.
Should I turn on test retries to hide flaky tests?
Retries keep a build green, but they also hide the problem. Count a test that passes on a retry as flaky, report it, and fix or quarantine it.
How many times should I repeat a test to know it is stable?
Repeat the test on the machine that flakes. With an independent 5 percent chance of failure on each run, at least one failure appears in about 64 percent of 20-run batches, compared with 5 percent of single runs. Clean runs do not prove the test is stable.



