iOS with SwiftUI · Production: Architecture, Testing, Accessibility and the App Store

Testing iOS Apps with Swift Testing and XCUITest

Unit-test view models and logic with Swift Testing, including async code and parameterized tests, and cover critical flows with UI tests.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

Unit tests for the orders feature (loading, cancelling and the failure rollback), parameterized tests for price formatting, and one UI test for the checkout flow.

The idea

  • Swift Testing (import Testing) is the modern framework included with Xcode 16 and later: @Test functions, #expect for checks that continue, #require for checks that stop the test, arguments: for parameterized tests, and async tests that await directly.
  • Test view models and services with fakes; this is where most logic lives and it runs in milliseconds.
  • UI tests (XCTest's XCUIApplication) launch the app and tap through it using accessibility identifiers. They're slower and more brittle, so keep them for critical flows such as sign-in and checkout.
  • Run tests with Cmd-U in Xcode, or xcodebuild test in CI.

The code

// UI test (XCTest), for the critical checkout flow
final class CheckoutUITests: XCTestCase {
    func testCheckout() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-uiTesting"]           // the app uses fake services in this mode
        app.launch()
        app.buttons["add-to-cart-mug-01"].tap()
        app.buttons["Cart"].tap()
        app.buttons["Place order"].tap()
        XCTAssertTrue(app.staticTexts["Order placed"].waitForExistence(timeout: 2))
    }
}

Try the logic

The orders view model's tests, run with Swift Testing (the last test is wrong on purpose, to show how a failure is reported):

Unit tests with Swift Testing
import Testing

struct Order: Equatable, Sendable { let id: Int; let total: Double }
struct ServerError: Error {}

struct FakeOrderService: Sendable {
    var stored: [Order] = []
    var failingCancels: Set<Int> = []
    func orders() async throws -> [Order] { stored }
    func cancel(orderID: Int) async throws { if failingCancels.contains(orderID) { throw ServerError() } }
}

@MainActor
final class OrdersModel {
    private(set) var orders: [Order] = []
    private(set) var error: String?
    let service: FakeOrderService
    init(service: FakeOrderService) { self.service = service }
    func load() async { orders = (try? await service.orders()) ?? [] }
    func cancel(_ order: Order) async {
        let previous = orders
        orders.removeAll { $0.id == order.id }
        do { try await service.cancel(orderID: order.id) }
        catch { orders = previous; self.error = "Couldn't cancel order #\(order.id)." }
    }
}

func priceLabel(_ cents: Int) -> String { "EUR \(cents / 100).\(cents % 100 < 10 ? "0" : "")\(cents % 100)" }

@MainActor @Test func loadsOrders() async {
    let model = OrdersModel(service: FakeOrderService(stored: [Order(id: 1, total: 40)]))
    await model.load()
    #expect(model.orders.map(\.id) == [1])
}

@MainActor @Test func failedCancelRollsBack() async throws {
    let model = OrdersModel(service: FakeOrderService(stored: [Order(id: 2, total: 15)], failingCancels: [2]))
    await model.load()
    let order = try #require(model.orders.first)
    await model.cancel(order)
    #expect(model.orders == [order])
    #expect(model.error == "Couldn't cancel order #2.")
}

@Test(arguments: [(1999, "EUR 19.99"), (500, "EUR 5.00"), (7, "EUR 0.07")])
func formatsPrices(cents: Int, expected: String) {
    #expect(priceLabel(cents) == expected)
}

@Test func roundsTotals() {
    #expect(priceLabel(1005) == "EUR 10.5")             // wrong expectation: shows how failures look
}

Output

✔ Test loadsOrders() passed after <time> seconds.
✔ Test failedCancelRollsBack() passed after <time> seconds.
✔ Test formatsPrices(cents:expected:) with 3 test cases passed after <time> seconds.
✘ Test roundsTotals() recorded an issue at AppTests.swift:51:5: Expectation failed: priceLabel(1005) == "EUR 10.5"
↳ priceLabel(1005) == "EUR 10.5" → false
↳   priceLabel(1005) → "EUR 10.05"
✘ Test roundsTotals() failed after <time> seconds with 1 issue.
✘ Test run with 4 tests in 0 suites failed after <time> seconds with 1 issue.

Run this on your computer — see “Get Started” for setup.

The failure report shows the expression and both values, which usually points straight at the bug (here, the expectation).

Common mistakes

  • Testing through the UI only. UI tests are slow and flaky; test logic in view models directly.
  • Real networking in tests. Inject fakes; tests must pass offline and in any order.
  • Fixed sleeps in UI tests. Use waitForExistence(timeout:) instead of sleep.
  • No accessibility identifiers. UI tests that find buttons by visible text break with every copy or language change.

Key points

  • Swift Testing: @Test, #expect, #require, parameterized and async tests.
  • Most tests should target view models and services with fakes.
  • Keep a few UI tests for critical flows, using accessibility identifiers.
  • Failure messages show the values involved; write expectations that read clearly.

Further reading: Swift Testing, XCUIAutomation.

Practice questions

What's the difference between #expect and #require in Swift Testing?

Answer

#expect records a failure and lets the test continue, so you see every failed check. #require throws and stops the test when its condition fails, and also unwraps optionals (let x = try #require(optional)), so later code can rely on the value.

Why should most iOS tests be unit tests rather than UI tests?

Answer

Unit tests of view models and services run in milliseconds, are deterministic with fakes and pinpoint failures. UI tests launch the whole app, are slow, depend on timing and break with UI changes, so they're best kept for a few critical end-to-end flows.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?