Modern iOS testing stack: fast unit tests + UI tests that don’t flake
A pragmatic iOS testing setup: keep unit tests fast, make UI tests stable, and add one verification loop that catches regressions without turning CI into a lottery.
A modern iOS test suite has one job: tell you the truth quickly.
That requires two things at the same time:
- fast feedback (unit tests and lightweight integration tests)
- trustworthy end-to-end coverage (UI tests that fail for real reasons)
If either half is missing, teams compensate with manual checks and ship fear.
The stack: what to run, where, and why
A practical breakdown that scales beyond a solo project:
1) Unit tests (the default)
Use unit tests for:
- pure logic (formatting, parsing, pricing rules)
- state machines (navigation state, reducers, workflows)
- small services behind protocols (caching policy, retry policy)
Design rule: unit tests should not touch the network, the keychain, or the file system unless the test explicitly exists to cover that.
Speed targets that keep the suite usable:
- typical unit test file: under 1 second
- full unit test suite (developer machine): under 60 seconds
2) Integration tests (narrow, deterministic)
Integration tests are where you validate boundaries without paying the UI test tax.
Examples:
- API client against a local stub server
- persistence stack with an in-memory store
- feature module wired with real dependencies except for network
These tests are slower than unit tests, so keep them selective.
3) UI tests (few, stable, high value)
UI tests are expensive. Make them earn their keep.
Good UI test candidates:
- login or onboarding happy path
- one critical purchase or upgrade flow
- one navigation smoke test across the main tabs
Avoid trying to test every edge case through UI.
Make UI tests stable: control the app
Flakiness is usually a control problem, not an XCTest problem.
Use launch arguments to put the app into known states
Make the app configurable for tests:
- disable animations
- force a fixed locale and time zone
- seed deterministic data
- route networking through stubs
Example pattern:
// In the app target
enum AppLaunchFlag {
static let uiTesting = "UI_TESTING"
static let disableAnimations = "DISABLE_ANIMATIONS"
}
@main
struct MyApp: App {
init() {
if ProcessInfo.processInfo.arguments.contains(AppLaunchFlag.uiTesting) {
TestHooks.install()
}
}
var body: some Scene { WindowGroup { RootView() } }
}
// In the UI test target
let app = XCUIApplication()
app.launchArguments += [
"UI_TESTING",
"DISABLE_ANIMATIONS",
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
"-AppleTimeZone", "Europe/Zagreb"
]
app.launch()
If you cannot reproduce the same starting state, you cannot debug failures efficiently.
Prefer explicit waits for meaningful conditions
Do not sleep.
Waiting should be tied to a condition you care about.
let loginButton = app.buttons["login.primary"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
let homeTitle = app.staticTexts["home.title"]
XCTAssertTrue(homeTitle.waitForExistence(timeout: 10))
This does two things:
- removes timing assumptions
- gives you a concrete assertion when the state did not arrive
A common failure mode: UI tests pass locally and fail in CI
Failure mode:
- locally, a UI test passes consistently
- in CI, it intermittently fails with “element not hittable” or a missing element
Diagnosis checklist that finds real root causes:
- Capture a screenshot and UI hierarchy on failure.
- Print the app state you control: locale, time zone, feature flags, stub mode.
- Check for animation, transition, or keyboard state that changes hit testing.
A frequent cause is an overlay that exists briefly in CI due to slower rendering.
For example:
- a loading spinner blocks taps while a request is in flight
- the test taps an element as soon as it exists, but before it is actually tappable
Fix:
- wait for the overlay to be gone, not just for the target element to exist
- ensure the app uses stubbed, fast responses in UI testing mode
Pattern:
let loadingOverlay = app.otherElements["loading.overlay"]
if loadingOverlay.exists {
let predicate = NSPredicate(format: "exists == false")
expectation(for: predicate, evaluatedWith: loadingOverlay)
waitForExpectations(timeout: 10)
}
let paywallCTA = app.buttons["paywall.cta"]
XCTAssertTrue(paywallCTA.waitForExistence(timeout: 5))
XCTAssertTrue(paywallCTA.isHittable)
If you cannot identify the overlay or transient blocker, add accessibility identifiers for it. Treat that as production-quality instrumentation, not a testing hack.
The verification loop: measure flake rate, not vibes
A simple, specific verification step that keeps teams honest:
- pick your top 10 UI tests
- run them 30 times on the same CI executor (same device model and OS)
- record pass rate and median duration per test
If a test passes 28 out of 30 runs, it is not “mostly fine”. It is a broken signal.
Once you have the baseline:
- require 30 out of 30 for any test that gates merges
- treat any regression in duration as a performance bug in the test or the app
This turns flakiness into a measurable defect you can fix and prevent.
Keep the suite fast: structure and tactics
A few patterns that typically pay off:
- run unit tests on every commit
- run integration tests on every PR, but keep the set small
- run UI tests in parallel shards, and only for merge candidates
Within tests:
- avoid global shared state between tests
- reset user defaults and keychain in UI testing mode
- make network stubs deterministic, and make them fail loudly when unhandled
The last point matters. A silent fallback to real networking is one of the fastest ways to reintroduce flakiness.
What good looks like
A good iOS testing stack is boring in the best way:
- unit tests are fast enough to run constantly
- UI tests fail rarely, and when they do, the failure explains itself
- CI provides a stable signal you can trust when shipping