Skip to main content
All articles
SwiftUIFeatured

Where UI Tests Belong in a SwiftUI App

Ben Van AkenCo-Founder & CTO6 min read

We build and run a mid-sized SwiftUI business app: iPhone, iPad and Mac clients on a shared Swift package, talking to a Vapor server. Over the last year its UI-test layer grew to roughly 180 XCUITest classes in two lanes (macOS, and iOS/iPad) across about 25 suites. Most of what we know about it we learned by being wrong: a red we filed against the product that turned out to be the harness, or a green that had been vacuous for its entire life.

This series is the write-up. Every claim in it was measured on a real run rather than assumed. This first part is about the question that comes before any XCUITest is written: where UI tests sit at all, and what does not belong in one.

Where UI tests sit

Our testing strategy is a matrix of layers and domains. Five layers, each answering a different question, each in the framework that can actually answer it:

  • L1 Unit: does this function or ViewModel transform behave in isolation? Swift Testing for new tests, XCTest kept for the legacy corpus.
  • L2 Integration: does the real ViewModel, API, controller and database round-trip against a live server? Swift Testing plus a live-mode harness.
  • L3 UI: does the user-facing surface render and behave on screen? XCUITest drives and captures; an AI-vision judge validates screenshots against authored contracts.
  • L4 Migration: does the schema migrate against production-shaped data? A scripted backup-restore rehearsal, not an XCTest target at all.
  • L5 Performance: is it fast enough under load? k6 for the server, XCTMetric measure blocks for client micro-benchmarks.

The framework split is forced by Apple, not chosen. Swift Testing has no UI-automation primitive. XCUIApplication is the only native UI driver and the only thing Xcode Cloud runs for UI. So new logic tests go to Swift Testing, and XCTest survives for three things: the legacy corpus, all UI automation, and XCTMetric. We do not rewrite working XCTest into Swift Testing as busywork; we migrate a file when we are already in it.

The balance principle is simple to state and hard to keep: every behaviour is tested once, at the cheapest layer that can prove it. No under-testing, meaning every critical path, state transition, validation rule and permission boundary is covered somewhere. No over-testing, meaning a behaviour a fast unit test proves is not re-proven by a slow UI test. The shape that produces is a broad unit base, a substantial integration middle, and a deliberately thin UI top.

What belongs in a UI test, and only there: does it render, is the affordance reachable, does the screen show the right state, does this configuration flip actually hide the field. Pure logic, arithmetic, decoding and wire round-trips do not. The canonical over-test is an XCUITest for logic a unit test already proves.

The cheapest layer of all: source-scanning guards

One sub-layer is worth naming because it is nearly free. A source-scanning guard reads product source off disk and asserts an invariant: every window root applies the lock modifier; no confirmation dialog carries a cancel role on iOS; every ViewBuilder child that must exist does exist on both size classes. They need no app, no simulator, no server, and answer in milliseconds from a Swift package test target.

Some defects can only be caught this way. A missing ViewBuilder child compiles clean, emits no warning, and renders a perfectly happy screen; a UI flow only catches it if someone asserts the section's presence on a specific size class. Two rules keep these guards honest. First, give every one an explicit positive control, because zero violations and a broken scanner look identical. Second, a substring probe is a prefix probe. A check for the text struct Foo stayed green after the type was renamed FooRedproof, so pin the full declaration line.

Coverage that cannot fail is not coverage

Several shapes of green-but-worthless shipped real defects past a test that named them. We now review every new assertion against this list:

  • A negative oracle is not a success assertion. Asserting that a result is not a specific error passes on a different error one layer deeper. Pair every negative with the positive outcome.
  • A case must reach the code it claims to cover. A fixture that never creates the row the handler acts on lets the handler short-circuit and proves nothing. When a test is green in both directions, check this first.
  • A green can belong to a sibling. Two cases sharing mutable fixture state, such as a static launch-seed marker or a shared app container, can pass for each other's reasons. Make shared things per-instance.
  • Proving something did not happen needs a contrast, not an absence. No request reached the provider is also true when the request failed for an unrelated reason. Pair it with a control where it demonstrably does happen, differing by exactly one input.
  • An aggregate over an empty population passes. A percentile or failure-rate threshold with zero samples is vacuously satisfied. Assert the sample count beside every aggregate.
  • An injected seam whose nil case falls back to Locale.current or TimeZone.current measures the developer's machine, not the fixture. nil must mean absent, never ask the environment.
  • An acceptance check whose expected value is read off the artifact it inspects cannot fail. Ask of any new guard: what would have to be true for this to report a violation its author did not already know about?

Next up (Part 2): Designing an XCUITest Harness the App Cooperates With

Not sure what your SwiftUI app should be testing where?

We design test strategies for production SwiftUI apps that cover every critical path once, at the cheapest layer that proves it. Book a call and let's map yours.