iOS – Testing an iOS App

September 9, 20268 min readUpdated 9/18/2026

Most iOS testing advice is either "aim for 80% coverage" or "just write UI tests", and both produce suites that are slow, brittle and quietly ignored. This lesson is what is actually worth testing in a SwiftUI app, in the order the return diminishes — and the techniques for the parts that are genuinely awkward.

What to test, in order

Pure rules first. Pricing, validation, the cart reducer, date formatting. They cost nothing to test, they run in microseconds, and they are where a bug is most expensive.

The networking layer next, through a real URLSession against a stubbed transport, so the assertions are about the bytes you would actually send.

Then stores and view models against hand-written doubles. This is where behaviour lives — did signing out clear the token, does a cancelled payment stay silent.

Views, barely. Snapshot tests are brittle and UI tests are slow. The demo app has 130 tests and no UI test target at all, and the reason is worth stating: its three sibling web frontends are covered by Playwright suites that drive the real flows, and duplicating that here would mostly be testing SwiftUI rather than this app.

Pure rules

    func testADifferentToppingSetIsADifferentLine() {
        // Without this, a plain pepperoni and a pepperoni with extra cheese would collapse into one
        // line and the customer would be charged for the wrong pizza.
        var state = CartReducer.reduce(.initial, .add(item(toppings: [])))
        state = CartReducer.reduce(state, .add(item(toppings: [SampleData.toppings[4]])))

        XCTAssertEqual(state.items.count, 2)
    }

No view, no async, no main actor, no setup. Fifteen tests cover the cart's rules and run in about a millisecond together.

The ones worth writing carefully are the negative cases and the properties rather than the examples. That topping order does not make a different pizza. That reducing does not mutate the state it was given. That dropping a quantity to zero removes the line. Those are the rules that are hard to hold in your head and easy to break in a refactor.

Money in particular

    func testRoundingIsHalfUpRatherThanBankers() {
        // Swift's default `.rounded()` is `.toNearestOrEven`, which would give 0.12 here. A price
        // is rounded half UP, which is what a customer expects and what a till does.
        XCTAssertEqual(Money.rounded(0.125), 0.13)
        XCTAssertEqual(Money.rounded(0.135), 0.14)
    }

Two assertions that encode a decision somebody made deliberately. Without the test, a future refactor to value.rounded() looks harmless and changes what customers are charged by a cent in a way nobody would notice for months.

Testing the network for real

The usual advice is to hide URLSession behind a protocol and inject a fake. That tests the code around the session and skips the session itself — so it cannot catch a wrong HTTP method, a missing header, or a body that fails to encode, which are exactly the mistakes this layer makes.

final class StubURLProtocol: URLProtocol {
    /// Called with the outgoing request; returns the response and body to reply with.
    nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    /// A session wired to this protocol and nothing else.
    static func makeSession() -> URLSession {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [StubURLProtocol.self]
        return URLSession(configuration: configuration)
    }

URLProtocol sits underneath URLSession. The real session runs, builds the real request, and this hands back a canned response. So a test can assert on what was sent:

        XCTAssertNil(
            captured.value?.value(forHTTPHeaderField: "Authorization"),
            "A public endpoint must not leak the token."
        )

That is a security property, tested. The paired assertion checks the bearer token is attached when the endpoint asks for it, and together they prove the one flag in the whole networking layer behaves.

One rule, and it is not optional

    override func tearDown() {
        StubURLProtocol.handler = nil
        session = nil
        super.tearDown()
    }

The handler is process-global. A stale closure left behind answers the next test's request, producing a failure that moves when the test order changes — which is the most expensive kind to chase, because it looks like flakiness rather than a bug.

Doubles, written by hand

A mocking framework would remove the boilerplate and add a dependency, a code-generation step and a layer between a failing test and the reason it failed. At this size the boilerplate is cheaper — and a double you wrote is one you can read when a test starts failing for a reason the framework does not explain.

final class CatalogRepositorySpy: CatalogRepository, @unchecked Sendable {
    var products: [Product] = SampleData.products
    var toppings: [Topping] = SampleData.toppings
    var crusts: [Crust] = SampleData.crusts
    var error: Error?

    private(set) var productsCallCount = 0

Configurable results, and a record of what was asked for. Asserting on the call is what catches the bugs a return value cannot: a screen that fetches twice, a write that never happens, a request sent without authentication.

Testing async and main-actor code

This used to need expectations and timeouts. It no longer does — a test method can be async and await the thing it is testing:

    @MainActor
    func testARejectedTokenIsDiscardedRatherThanLeftBehind() async {
        let secureStore = InMemorySecureStore(seed: [.authToken: "expired.token"])
        let repository = AuthRepositorySpy(currentUserResult: .failure(APIError.unauthorized))
        let (store, _, tokenStore) = makeStore(repository: repository, secureStore: secureStore)

        await store.restoreSession()

        XCTAssertFalse(store.isAuthenticated)
        let remaining = await tokenStore.currentToken()
        XCTAssertNil(remaining)
    }

Linear, no callbacks, no timeout to tune. The two awaits are different actors — the store is main-actor isolated, the token store is an actor — and the test crosses both without ceremony.

@MainActor on the class does not compile cleanly

The tempting spelling is @MainActor final class AuthStoreTests: XCTestCase, and it produces a warning that becomes an error in Swift 6 — so effectively it does not compile going forward:

// warning: main actor-isolated class 'AuthStoreTests' has different actor
//          isolation from nonisolated superclass 'XCTestCase';
//          this is an error in Swift 6
@MainActor
final class AuthStoreTests: XCTestCase {
}

XCTestCase is nonisolated and a subclass may not add isolation its superclass does not have. Annotate each test method instead. It is more typing and it is more honest — the isolation belongs to the work, not to the fixture.

Testing a debounce without sleeping for real

The cart writes 300 ms after the last change, and a test that waits 300 ms is a test that makes the suite slow. Inject the interval:

        let store = CartStore(
            repository: repository,
            identifierStore: CartIdentifierStore(store: InMemoryKeyValueStore(seed: seed)),
            menuStore: menu,
            // Effectively no debounce, so a test does not have to sleep to observe a write.
            persistDebounce: .milliseconds(1)
        )

A timing value that is a constant is a timing value a test has to live with. Making it a constructor parameter with a production default costs nothing and turns an untestable behaviour into an ordinary one.

The most valuable test in that file asserts something does not happen:

        XCTAssertEqual(repository.replaceCallCount, 0)
        XCTAssertFalse(store.isHydrated)

Nothing is written before hydration — because writing an empty cart over a saved one is the worst bug this type can have, and it would only reproduce for customers who already had a basket.

Testing routes without a socket

Because an Endpoint is inert data, the whole routing layer is testable with plain assertions:

    func testMyOrdersCarriesPagingAndRequiresAuthentication() {
        let endpoint = OrderEndpoints.myOrders(page: 2, size: 10)

        XCTAssertEqual(url(endpoint), "http://localhost:8085/api/orders/mine?page=2&size=10")
        XCTAssertTrue(endpoint.requiresAuthentication)
    }

A path typo is caught by a test that never opens a socket. The version of this test that earns its place most is the one that loops:

        for endpoint in endpoints {
            XCTAssertTrue(endpoint.requiresAuthentication, "\(endpoint.path) must be authenticated")
            XCTAssertTrue(endpoint.path.hasPrefix("/api/me/"), "\(endpoint.path) must be under /api/me")
        }

Every profile route, asserted to be authenticated and to carry no user id in its path. That is a security property written as a test rather than as a comment — and a new endpoint added to the list is checked automatically, which a comment would not be.

Tests as documentation of a decision

The best tests in this suite are the ones whose names state a rule and whose failure messages explain it:

        XCTAssertEqual(
            repository.createCallCount, 0,
            "Do not create a cart server-side just because someone opened the app."
        )

Somebody who breaks that in two years gets the reasoning along with the failure, rather than a line number and a zero that was expected to be a zero.

Sample data that is not too tidy

Both the tests and the previews share one fixture file, and the data in it is deliberately awkward: a pizza with three sizes, a crust with a surcharge and two without, an address with a second line, a card with no expiry at all.

Fixtures that are too neat hide exactly the problems tests exist to catch. A PaymentMethod whose brand and last four digits are always present never exercises the branch that renders "Card •••• ????", and that branch is the one that will be hit by a real record from a payment provider having a bad day.

Arrange, act, assert

Every test above has the same three-part shape with blank lines between them, and it is worth being deliberate about. The arrange block builds the world, the act block is one line, and the assertions follow. When the act block is more than a line or two, that is usually a signal the test is covering two things and should be two tests.

The other habit worth forming: one factory per suite for the fixture, with defaulted parameters, so each test overrides only what it is actually about. makeStore(repository:) and item(toppings:) in this suite both exist for that reason, and they are why the tests read as statements of a rule rather than as setup with an assertion at the bottom.

What about UI tests?

XCUITest drives the real app through the accessibility layer. It is the only way to test a full flow end to end, and it is slow, brittle, and prone to failing for reasons that have nothing to do with your change.

The pragmatic position: a handful for the flows that would cost you money if they broke — sign in, add to cart, check out — and nothing else. And note the dependency, because it is a good argument for lesson 18: a UI test finds elements by accessibility identifier and label, so an app with good accessibility is an app that is straightforward to test, and one without is one where every test starts by adding identifiers.

Running them

xcodebuild test -project Pizza.xcodeproj -scheme Pizza \
    -destination 'platform=iOS Simulator,name=iPhone 15'

Coverage is enabled in the scheme, and it is worth reading as a map of what is untested rather than as a number to maximise. In this app the views show near zero and that is the expected result, not a gap — everything worth asserting was extracted into something that does not need rendering, which was the point of the architecture in the first place.

Next

Tests tell you whether the logic is right. Previews tell you whether the screen is, and the next lesson is them — plus the linting and formatting configuration worth having.