Skip to content
Commitwise
Menu
iOS Development

Testing with Swift Testing and UI tests

Write fast unit tests for an iOS app with Swift Testing (@Test,

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A refactor of the checkout view model ships on Friday. On Saturday, users can’t apply coupons, because the discount is now computed before the coupon is loaded. A ten-line test would have caught it in a second. The team had tests, but they hit the real server, took eight minutes, failed randomly, and nobody ran them anymore.

This tutorial covers Swift Testing, Apple’s modern testing framework (Xcode 16 and later), for fast tests of models and view models, and XCUITest for the few end-to-end UI tests worth having. The general ideas (what to test, test doubles, test structure) are in Unit testing.

New to some of these? Open what you need, or skip ahead.

Basics:What is a unit test?

A unit testUnit testA small, fast, automatic check that calls one piece of code with known input and compares the result with the expected value.Open in glossary → is a small piece of code that runs one part of your app, such as a function or a view model, with known inputs, and checks the result. Unit tests run in milliseconds, without a simulator screen or a network, so you can run hundreds of them after every change.

Basics:What is a test double?

A test doubleTest doubleA stand-in for a real dependency in a test, such as a fake payment gateway, so the test is fast, repeatable and safe.Open in glossary → stands in for a real dependency in a test: a fake API that returns fixed products instead of calling the server, or a fake clock that always says it’s noon. Your code receives it through its initializer, so the test decides how the world behaves.

Part Not started

Swift Testing basics

A test is like a checklist a pilot runs before takeoff: set up the situation, do one thing, check that the result is what it should be. In Swift Testing, a test is just a function marked @Test. Inside, #expect(...) checks a condition. When a check fails, Xcode shows you the actual values on both sides, so you rarely need to add print statements to find out what went wrong.

Swift Testing replaces XCTestCase subclasses and the XCTAssert* family with @Test functions, #expect and #require macros that capture sub-expressions, value-type suites (a fresh instance per test, so init/deinit replace setUp/tearDown), parameterized tests with argument-level results, and traits (.tags, .enabled(if:), .disabled, .bug, .timeLimit, .serialized). Tests run in parallel by default, in-process, and it coexists with XCTest in the same target, which matters because UI tests and performance tests still use XCTest.

import Testing
@testable import Shop

struct PriceCalculatorTests {
    let calculator = PriceCalculator(taxRate: 0.2)      // a fresh instance for every test

    @Test func addsTax() {
        #expect(calculator.total(subtotal: 100) == 120)
    }

    @Test("A percentage coupon is applied before tax")
    func percentageCoupon() throws {
        let coupon = try #require(Coupon(code: "SAVE10"))   // stops the test if nil
        #expect(calculator.total(subtotal: 100, coupon: coupon) == 108)
    }

    @Test(arguments: [
        (subtotal: 0, expected: 0),
        (subtotal: 10, expected: 12),
        (subtotal: 99.99, expected: 119.99),
    ])
    func totals(subtotal: Decimal, expected: Decimal) {
        #expect(calculator.total(subtotal: subtotal) == expected)
    }

    @Test func rejectsNegativeSubtotal() {
        #expect(throws: PriceError.negativeAmount) {
            try calculator.validatedTotal(subtotal: -5)
        }
    }
}
  • @Test marks a test; the string is an optional readable name for the test navigator.
  • #expect(a == b) checks and continues; on failure it shows both values, like 120.5 → 120.
  • try #require(x) unwraps an optional or checks a condition and stops the test if it fails, so the rest doesn’t crash on a nil.
  • arguments: runs the same test once per input, and each input shows up as its own result.
  • #expect(throws:) checks that code throws a specific error.
  • The struct gets a new instance for each test, so tests never share state through properties.

Predict the output

The totals test above has three arguments. The second one is wrong in the code under test. How many results does the test report?

Exerciseeasy

Test a password rule

PasswordRule.check(_:) returns a list of problems: .tooShort (under 8 characters), .noDigit, .noLetter. Write a parameterized test that checks four passwords and the exact problems each one should produce.

›

Pass pairs of (password, expected problems) as arguments. A Set of problems makes the comparison independent of order.

SolutionTry it yourself first
struct PasswordRuleTests {
    @Test(arguments: [
        ("abc", Set<PasswordProblem>([.tooShort, .noDigit])),
        ("12345678", [.noLetter]),
        ("abcdefgh", [.noDigit]),
        ("abcd1234", []),
    ])
    func problems(password: String, expected: Set<PasswordProblem>) {
        #expect(Set(PasswordRule.check(password)) == expected)
    }
}

When a case fails, the result shows which password it was and both sets, for example ["tooShort"] → ["tooShort", "noDigit"].

Traits and organization

  • Tags (@Tag static var checkout: Self) group tests across files for test plans and filtering.
  • Conditions: .enabled(if: ProcessInfo.processInfo.environment["CI"] != nil), .disabled("waiting for API v3"), .bug("https://…", "FB12345") for known failures (paired with withKnownIssue { } to keep the suite green while recording the issue).
  • .timeLimit(.minutes(1)) fails hung async tests instead of blocking CI.
  • Parallelism: tests and parameterized cases run concurrently. Global and static state, shared singletons, the file system and UserDefaults.standard cause order-dependent flakiness; inject them, or mark the suite .serialized as a last resort.
  • Migration: keep XCTest for UI tests and measure {} performance tests; convert unit tests file by file, since both frameworks run in the same target.
  • @Test functions, #expect (continues, captures sub-expressions), try #require (stops, unwraps).
  • Suites are types with per-test instances (init/deinit as setup/teardown); parameterized tests report per argument.
  • Traits: tags, .enabled(if:), .disabled, .bug + withKnownIssue, .timeLimit, .serialized.
  • Parallel by default: no shared static state. XCTest stays for UI and performance tests; both coexist.

Check this part

  1. What's the difference between #expect and #require?

  2. Why use @Test(arguments:) instead of a for loop in one test?

  3. Tests pass alone but fail randomly together. What's the likely cause in Swift Testing?

Swift Testing tests are functions marked @Test, checked with #expect (which shows the values on failure) and #require (which stops the test). Suites are types; parameterized tests run one function over many inputs; traits add names, tags, conditions and time limits. Tests run in parallel by default.

Part Not started

Testing async code and view models

Most of an app’s logic lives in view models: load data, handle errors, react to taps. They’re perfect to test, as long as they don’t create their own dependencies. Pass the API in through the initializer, and the test can hand over a fake that returns exactly what the test needs, instantly, every time.

Testable view models take protocol-typed dependencies (or closures) and expose state; tests are async, run on the right actor (@MainActor on the test or suite when the model is main-actor isolated), and await the operation directly. Fire-and-forget Task {} inside view models makes tests racy; expose async methods and let the view create the task, or keep a handle the test can await. Time is a dependency: inject a Clock (swift-clocks’ TestClock/ImmediateClock) instead of Task.sleep on the continuous clock. confirmation() replaces expectations for events that must happen a given number of times.

protocol ProductLoading: Sendable {
    func products() async throws -> [Product]
}

@MainActor @Observable
final class ProductListModel {
    enum State: Equatable { case idle, loading, loaded([Product]), failed(String) }
    private(set) var state: State = .idle
    private let loader: any ProductLoading

    init(loader: any ProductLoading) { self.loader = loader }

    func load() async {
        state = .loading
        do { state = .loaded(try await loader.products()) }
        catch { state = .failed("Couldn't load products.") }
    }
}

struct FakeLoader: ProductLoading {
    var result: Result<[Product], Error>
    func products() async throws -> [Product] { try result.get() }
}

@MainActor
struct ProductListModelTests {
    @Test func showsProducts() async {
        let lamp = Product(id: 1, name: "Desk lamp", price: 34.99)
        let model = ProductListModel(loader: FakeLoader(result: .success([lamp])))

        await model.load()

        #expect(model.state == .loaded([lamp]))
    }

    @Test func showsAnErrorWhenLoadingFails() async {
        let model = ProductListModel(loader: FakeLoader(result: .failure(URLError(.notConnectedToInternet))))

        await model.load()

        #expect(model.state == .failed("Couldn't load products."))
    }
}
  • The protocol ProductLoading lets the app pass the real network loader and the test pass FakeLoader.
  • await model.load() waits until loading is completely done, so the check sees the final state.
  • Arrange, act, assert: create the model with a fake, call one method, check the state. Each test checks one behavior, and its name says which.
  • @MainActor on the suite, because the model is main-actor isolated.
Spot the bugA test that passes, sometimes

Tap the line with the bug.

@MainActor
struct CartModelTests {
    @Test func applyingACouponUpdatesTheTotal() {
        let model = CartModel(api: FakeCartAPI(discountPercent: 10))
        model.subtotal = 100
        model.applyCouponTapped(code: "SAVE10")   // starts Task { await apply(code) }
        #expect(model.total == 90)
    }
}

Events, time and callbacks

@Test func savingTwiceSendsTwoAnalyticsEvents() async {
    await confirmation("analytics event", expectedCount: 2) { sent in
        let model = DraftModel(analytics: FakeAnalytics { _ in sent() })
        await model.save()
        await model.save()
    }
}

@Test func searchWaitsForTypingToPause() async {
    let clock = TestClock()
    let api = RecordingSearchAPI()
    let model = SearchModel(api: api, clock: clock)

    model.queryChanged("la")
    model.queryChanged("lamp")
    await clock.advance(by: .milliseconds(300))      // no real waiting

    #expect(await api.queries == ["lamp"])
}

confirmation fails if the callback is called more or fewer times than expected. A TestClock (from swift-clocks, or your own Clock conformance) makes debounces, timeouts and retries deterministic and instant. For code that must finish its callback within the closure, structure the API so the test can await it; confirmation doesn’t wait for work that escapes its body.

  • Protocol or closure dependencies; test public state; @MainActor suites for main-actor models.
  • Expose async operations; views own Task creation. Never assert after starting an unstructured task; never sleep in tests.
  • confirmation(expectedCount:) for events; injected Clock (TestClock, ImmediateClock) for debounce, timeouts and retries.

Check this part

  1. How does a test give a view model fake data instead of calling the server?

  2. Why is adding Task.sleep to a test a bad fix?

  3. How do you test a 300 ms search debounce without waiting?

Test view models through their public state: inject fakes for the API and a controllable clock, await the async method, then check the state. Never assert right after starting an unstructured Task. Use confirmation() for callbacks and events.

Part Not started

UI tests with XCUITest

Unit tests check the parts; a UI test checks the whole app, the way a user would: it launches the app, taps buttons, types text and checks what appears on screen. They’re slow (seconds each) and more fragile, so keep them for the flows that must never break: sign in, add to cart, check out.

UI tests use XCTest with XCUIApplication, and they find elements through the same accessibility information VoiceOver uses.

XCUITest runs in a separate process and drives the app through the accessibility layer, so it tests the real build including navigation, layout and system integration. Its costs are speed and flakiness: keep a thin layer (the testing pyramid), query by stable accessibilityIdentifiers, rely on implicit and explicit waits (waitForExistence(timeout:)), and control the environment through launch arguments and environment variables (stubbed network, fresh state, disabled animations, fixed locale). Page objects (screen types with intent-level methods) keep tests readable as the UI changes.

// In the app: identifiers for testing, and a switch for stubbed data
Button("Add to cart") { model.add(product) }
    .accessibilityIdentifier("product.addToCart")

@main
struct ShopApp: App {
    let environment: AppEnvironment =
        ProcessInfo.processInfo.arguments.contains("-uiTesting") ? .stubbed : .live
    var body: some Scene { WindowGroup { RootView(environment: environment) } }
}

// In the UI test target
final class CheckoutUITests: XCTestCase {
    private var app: XCUIApplication!

    override func setUp() {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["-uiTesting", "-AppleLanguages", "(en)"]
        app.launch()
    }

    @MainActor
    func testAddingAProductShowsItInTheCart() {
        app.buttons["product.lamp"].tap()
        app.buttons["product.addToCart"].tap()
        app.tabBars.buttons["Cart"].tap()

        let row = app.staticTexts["cart.item.lamp"]
        XCTAssertTrue(row.waitForExistence(timeout: 5))
        XCTAssertEqual(app.staticTexts["cart.total"].label, "$34.99")
    }
}
  • accessibilityIdentifier gives an element a stable name for tests that doesn’t change when the button’s text is translated or reworded.
  • -uiTesting is a launch argument the app checks to use stubbed data instead of the real server, so the test is fast and always sees the same products.
  • waitForExistence(timeout:) waits up to 5 seconds for an element to appear and continues as soon as it does.
  • continueAfterFailure = false stops at the first failure, because later steps make no sense on the wrong screen.

Production puzzles

Spot the bugGreen locally, red on CI

Tap the line with the bug.

func testLogin() {
    let app = XCUIApplication()
    app.launch()
    app.textFields["Email"].tap()
    app.textFields["Email"].typeText("ana@example.com")
    app.secureTextFields["Password"].tap()
    app.secureTextFields["Password"].typeText("correct-horse")
    app.buttons["Sign in"].tap()
    sleep(3)
    XCTAssertTrue(app.staticTexts["Welcome back, Ana"].exists)
}

Predict the output

A suite of 30 Swift Testing tests uses a shared static FakeDatabase.shared and resets it at the start of each test. Locally they pass in Xcode one by one; running the whole suite, a few fail at random. Why?

  • Thin UI-test layer for critical flows; XCUITest drives the accessibility tree from another process.
  • Stable accessibilityIdentifiers, waitForExistence (never sleep), page objects.
  • Control the environment with launch arguments: stubbed network, fresh state, fixed locale, no animations; erase simulators on CI.
  • Attach screenshots and recordings on failure.

Check this part

  1. Why find buttons by accessibilityIdentifier instead of their text?

  2. Which flows deserve UI tests?

  3. How should a UI test avoid depending on the real backend?

XCUITest drives the real app from outside, like a user. Keep UI tests few and focused on critical flows, find elements by accessibility identifier, wait for elements instead of sleeping, and stub the network with launch arguments so tests are fast and deterministic.

Key takeaways

  • Write tests as @Test functions with #expect; use #require for things the test can’t continue without.
  • Parameterized tests check many inputs, each with its own result.
  • Pass fakes into view models and await their async methods before checking state.
  • Keep a few UI tests for critical flows, using identifiers, waits and stubbed data.
  • Swift Testing: per-test suite instances, traits, parallel by default; XCTest for UI and performance.
  • Expose async operations; inject clocks; confirmation for events; no sleeps.
  • UI tests: page objects, launch-argument environments, fresh simulators, failure attachments.
  • Flakiness comes from shared state, real backends, time and text; remove each one.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.