Back to Blog

Modern iOS testing stack: fast unit tests + stable UI tests + strategy

A useful iOS testing stack is less about tool choice and more about test boundaries, promotion rules, and keeping slow checks rare enough that people still trust the signal.

11 min read

A lot of teams say they want “better tests” when what they actually want is fewer unpleasant surprises.

Those are related, but not the same.

The testing stack matters less than the decisions behind it:

  1. what deserves a fast test
  2. what deserves a slow test
  3. what gets promoted from local confidence to merge-blocking CI
  4. what you stop pretending is worth automating

If you get those boundaries right, the stack stays useful as the app grows. If you get them wrong, you end up with a large suite that mostly teaches engineers to click “re-run”.

1. Start with failure economics, not framework enthusiasm

The wrong way to design a test strategy is by asking which framework is modern.

The right way is to ask:

  • what failures are common in this codebase?
  • which ones are expensive to discover late?
  • which ones are cheap to catch near the code that caused them?

For most iOS apps, the repeat offenders are boring:

  • state transitions that regress quietly
  • networking and decoding mismatches
  • persistence edge cases
  • feature-flag combinations nobody thought through
  • UI flows that break because one async assumption changed

That naturally leads to a stack with three layers:

  1. fast logic tests for rules and state
  2. narrow boundary tests for integration points
  3. few high-value UI flows for end-to-end confidence

The mistake is making layer 3 compensate for weak layers 1 and 2.

That is how teams accidentally build a slow, flaky system whose only job is to prove they did not isolate their logic properly.

2. Unit tests should mostly test decisions, not plumbing

A healthy unit-test layer is where you verify:

  • reducer transitions
  • eligibility rules
  • date/price/content formatting rules
  • retry, backoff, deduplication, and caching policy
  • mapping from backend payloads into product-facing state

These are the tests that should run constantly and finish fast enough that nobody hesitates.

A useful smell test:

  • if the test needs a simulator, it is not a unit test
  • if the test needs five mocks to assert one boolean, the design probably needs work
  • if the test breaks every time you rename internal implementation details, it is too coupled

Good unit tests usually sit at the seam where the product makes a decision.

For example, a paywall presentation rule often matters more than the exact view hierarchy that eventually renders it.

struct PaywallPolicy: Sendable {
    var hasActiveSubscription: Bool
    var hasDismissedIntroOffer: Bool
    var remoteUpsellEnabled: Bool

    func shouldShowPaywall(on action: UserAction) -> Bool {
        guard remoteUpsellEnabled else { return false }
        guard hasActiveSubscription == false else { return false }

        switch action {
        case .appLaunch:
            return false
        case .exportTapped:
            return true
        case .premiumFilterSelected:
            return hasDismissedIntroOffer
        }
    }
}

That is the kind of logic that deserves a dense little matrix of tests.

Not because it is glamorous. Because product bugs tend to live there.

3. Boundary tests are where most teams are too weak

A lot of iOS codebases have two extremes:

  • many tiny unit tests
  • a small pile of UI tests

What is missing is the middle.

That middle is where you test the app’s important boundaries without paying the full UI tax.

Typical candidates:

  • API client + decoding + error mapping
  • persistence store + migrations + query behavior
  • feature module wired with real collaborators except transport
  • analytics event emission from meaningful user actions
  • notification/deep-link routing into app state

These tests are where you verify that real pieces fit together.

They catch the bugs that mocks politely ignore.

A practical example: networking boundary

This is a better use of test time than mocking a client in twenty view model tests.

struct Endpoint<Response: Decodable> {
    let path: String
    let method: String
}

protocol HTTPSending: Sendable {
    func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

struct APIClient: Sendable {
    let baseURL: URL
    let session: HTTPSending
    let decoder: JSONDecoder

    func send<Response>(_ endpoint: Endpoint<Response>) async throws -> Response {
        var request = URLRequest(url: baseURL.appending(path: endpoint.path))
        request.httpMethod = endpoint.method

        let (data, response) = try await session.data(for: request)

        guard 200..<300 ~= response.statusCode else {
            throw APIError.httpStatus(response.statusCode)
        }

        return try decoder.decode(Response.self, from: data)
    }
}

The useful test here is not “did I call the protocol mock once.”

It is:

  • can this endpoint decode the real payload shape?
  • do we map a 401, 429, or malformed response to the right product-level error?
  • does retry policy treat transient failures differently from permanent ones?

That is boundary coverage. It pays rent.

4. UI tests should verify contracts, not every branch

UI tests are expensive in three ways:

  1. runtime
  2. maintenance cost
  3. diagnostic ambiguity when they fail badly

So the rule should be blunt: UI tests only cover flows whose failure would be embarrassing, expensive, or otherwise hard to detect below the UI.

Good examples:

  • onboarding completes and lands in the expected signed-in state
  • purchase flow reaches the right confirmation state
  • document creation or export works through the visible product path
  • navigation into a critical feature still works after app startup

Bad examples:

  • every error copy variant
  • every validation rule already covered in logic tests
  • every feature-flag branch
  • visual trivia that only changed because spacing shifted by one point

If a UI suite is large because it is compensating for distrust in lower layers, fix the lower layers.

The simulator is a poor place to discover ordinary business-logic mistakes.

5. Stable UI tests require app-level test hooks, not hope

Most flaky UI tests are not caused by XCTest. They are caused by the app refusing to behave deterministically under test.

The app should have a deliberate testing mode that can:

  • disable non-essential animations
  • force locale, calendar, and time zone
  • seed fixed data
  • choose stubbed or controlled backend responses
  • bypass one-off onboarding noise when the test is not about onboarding

That does not make the app “special for tests.” It makes the app controllable.

A small setup goes a long way:

enum LaunchOption {
    static let uiTesting = "UI_TESTING"
    static let disableAnimations = "DISABLE_ANIMATIONS"
    static let seedScenario = "SEED_SCENARIO"
}

@main
struct MyApp: App {
    init() {
        let arguments = ProcessInfo.processInfo.arguments

        if arguments.contains(LaunchOption.uiTesting) {
            TestEnvironment.install(arguments: arguments)
        }
    }

    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

And from the test side:

let app = XCUIApplication()
app.launchArguments += [
    "UI_TESTING",
    "DISABLE_ANIMATIONS",
    "SEED_SCENARIO", "signed_in_with_project"
]
app.launch()

If you cannot put the app into a known state on demand, the suite will eventually become folklore.

6. Promotion rules matter more than test count

One reason test suites rot is that every new test gets the same social status.

That is a mistake.

Not all tests deserve to gate merges.

A better model is to promote tests in stages.

Stage 1: local-only while the code is still moving

When a feature is new, you may write tests that help development but are not stable enough to block CI yet.

That is fine.

Stage 2: PR-visible but non-blocking

Useful for:

  • broad integration coverage
  • newly added UI flows
  • tests with value but not enough track record yet

Stage 3: merge-blocking only after they prove themselves

A test should become blocking only if:

  1. it has low false-positive behavior
  2. failures are diagnosable
  3. the team agrees the covered behavior is critical

That last part gets skipped surprisingly often.

Blocking CI with mediocre tests is a nice way to teach the team that “quality” is an obstacle rather than a safety net.

7. Treat flakiness like a product bug with an owner

“Some tests are flaky” is just another way of saying the team has normalized false alarms.

That corrodes trust faster than almost anything else.

The fix is not philosophical. It is operational:

  1. record retry rate or rerun frequency
  2. identify the worst offenders
  3. assign an owner
  4. quarantine or demote the test if needed
  5. fix the app or the test harness properly

A useful internal rule is:

  • if a test needs routine reruns, it is not healthy enough to gate merges

There is no honor in keeping a flaky test at the front door. That is security theatre with extra build minutes.

8. Design the codebase so test seams are obvious

A good testing stack is easier to maintain when the architecture has clean boundaries.

That usually means:

  • feature logic separated from framework glue
  • transport hidden behind a small client
  • persistence behind explicit interfaces
  • side effects coordinated in a small number of places
  • state transitions represented explicitly

For example, view models that directly reach into URLSession, UserDefaults, notification centers, and analytics are not just messy. They also force awkward tests.

You end up with a familiar anti-pattern:

  • lots of mocks
  • lots of fragile expectations
  • very little confidence

A slightly more deliberate design keeps the test surface honest.

enum SaveDraftResult: Equatable, Sendable {
    case saved(id: Draft.ID)
    case validationFailed([ValidationError])
    case blockedBySync
}

protocol DraftSaving: Sendable {
    func save(_ input: DraftInput) async throws -> SaveDraftResult
}

Now tests can focus on outcomes and state transitions, not on whether three collaborators were poked in the “correct” order.

9. Add one explicit “release confidence” lane

Teams often have many tests but no single release-oriented confidence pass.

That is a gap.

I like one deliberate lane that answers:

  • can the app build from clean state?
  • do core logic tests pass?
  • do a few critical end-to-end flows still work?
  • are obvious regressions in signing/configuration/assets caught?

This is not the same as “run everything.”

It is a curated set for shipping confidence.

A decent release lane is small, predictable, and intentionally boring.

For many apps, that means:

  1. full build
  2. unit + boundary tests
  3. a handful of critical UI flows
  4. optional archive/export smoke check if release process is fragile

The point is to represent real shipping risk, not engineering guilt.

10. Stop over-automating what changes too often

There is a class of test that sounds responsible but tends to age badly:

  • highly detailed snapshot suites for volatile screens
  • exhaustive UI coverage for fast-changing onboarding or marketing surfaces
  • tests asserting internal implementation sequencing instead of outcomes

Those can be worth it in narrow cases, but not by default.

If a surface changes weekly, a brittle automation layer may cost more than it saves.

That does not mean “test less.” It means shift the test down a layer or change the assertion.

Examples:

  • assert semantic state instead of pixel-perfect UI when layout is not the risk
  • test route resolution below the UI when deep-link parsing is the real concern
  • test formatter outputs directly instead of screenshots of labels

The job is not to maximize automation. The job is to maximize useful signal per minute spent maintaining it.

11. A simple stack that usually holds up

If I had to keep it compact for a growing iOS app, I would use this shape:

  1. Fast logic tests for reducers, policies, and product rules.
  2. Boundary tests for networking, persistence, event routing, and module wiring.
  3. Few UI flows for critical paths only.
  4. Promotion rules so only proven tests gate merges.
  5. A release-confidence lane that reflects actual shipping risk.
  6. Ongoing flake tracking so trust stays high.

That stack is not trendy. That is part of the appeal.

12. The question worth asking every month

Not “do we have enough tests?”

Ask this instead:

  1. Which failures reached QA or production that should have been caught earlier?
  2. Which tests waste time without changing decisions?
  3. Which merge-blocking checks would we keep if we had to cut the suite in half?

Those answers usually tell you more than another debate about frameworks.

Final take

A modern iOS testing stack is not modern because it uses the latest tooling.

It is modern if it respects reality:

  • fast checks should be very fast
  • slow checks should be rare and valuable
  • UI tests should cover contracts, not insecurity
  • merge blockers should earn the right to block

Do that well and the suite becomes what it should be: a calm, credible signal.

Not a ritual.