Start a project

Article · iOS · Testing · Quality · 7 min read

Testing iOS apps with Swift Testing and XCTest

Tests are what let you change an app without fear. They matter most a year after launch, when a new iOS version is out, the original developer has moved on, and someone needs to fix a bug in a part of the app nobody has touched for months. This guide covers what’s worth testing in an iOS app, how to do it with Apple’s Swift Testing framework, and where XCTest still fits.

On this page 9 sections
  1. What to test, and what not to
  2. Swift Testing basics
  3. Parameterised tests
  4. Testing async code and models
  5. Test doubles: stubs, fakes and spies
  6. Traits: tags, conditions and time limits
  7. Where XCTest still fits
  8. Running tests on every change
  9. Key takeaways

What to test, and what not to

A test suite is code you maintain, so spend it where it pays back:

Less valuable: tests that restate SwiftUI layout, tests of Apple’s frameworks, and tests so tied to implementation details that every refactor breaks them.

Swift Testing basics

Swift Testing ships with Xcode 16 and later. Tests are functions marked @Test, grouped in plain types, and they check results with the #expect macro:

import Testing
@testable import Shop

struct BasketTests {
    @Test func totalIncludesEveryLine() {
        var basket = Basket()
        basket.add(.init(name: "Mug", unitPrice: 8_00, quantity: 2))
        basket.add(.init(name: "Coaster", unitPrice: 2_50, quantity: 4))

        #expect(basket.totalInPence == 26_00)
    }

    @Test("An empty basket can't be checked out")
    func emptyBasketCannotCheckOut() {
        #expect(Basket().canCheckOut == false)
    }
}

When #expect fails it shows the values on both sides of the comparison, so you rarely need custom failure messages. Storing money as whole pence, as here, avoids floating-point rounding surprises; Swift’s Decimal is the other sensible option.

Use #require when the rest of the test can’t continue without a value. It throws, ending the test cleanly:

@Test func decodesOrderFromServerJSON() throws {
    let json = Data(#"{"id":"A12","total_pence":2600,"status":"dispatched"}"#.utf8)
    let order = try JSONDecoder().decode(OrderDTO.self, from: json)

    let status = try #require(OrderStatus(rawValue: order.status))
    #expect(status == .dispatched)
    #expect(order.totalPence == 2_600)
}

Each test runs on a fresh instance of its containing type, so set up shared fixtures in init() and they’re never shared between tests.

Parameterised tests

Many rules are best tested with a table of inputs and expected outputs. Swift Testing runs each argument as its own test case, and reports which one failed:

struct PostcodeValidatorTests {
    @Test(arguments: [
        "SW1A 1AA",
        "M1 1AE",
        "B33 8TH",
        "CR2 6XH",
        "DN55 1PT",
    ])
    func acceptsValidPostcodes(_ input: String) {
        #expect(PostcodeValidator.isValid(input))
    }

    @Test(arguments: ["", "SW1A", "12345", "SW1A 1AAA"])
    func rejectsInvalidPostcodes(_ input: String) {
        #expect(!PostcodeValidator.isValid(input))
    }
}

To pair inputs with expected outputs, pass zip(inputs, expected) and give the test function two parameters.

Testing async code and models

Test functions can be async and throws. Mark a suite @MainActor when it exercises main-actor models:

@MainActor
struct LibraryModelTests {
    @Test func loadingSuccessShowsBooks() async {
        let dune = Book(id: UUID(), title: "Dune", author: "Frank Herbert")
        let model = LibraryModel(service: StubBookService(result: .success([dune])))

        await model.load()

        #expect(model.books == [dune])
        #expect(model.errorMessage == nil)
        #expect(model.isLoading == false)
    }

    @Test func loadingFailureShowsMessageAndKeepsOldBooks() async {
        let service = SwitchableBookService()
        let model = LibraryModel(service: service)
        service.next = .success([Book(id: UUID(), title: "Emma", author: "Jane Austen")])
        await model.load()

        service.next = .failure(URLError(.notConnectedToInternet))
        await model.load()

        #expect(model.books.count == 1)
        #expect(model.errorMessage != nil)
    }

    @Test func searchMatchesTitlesIgnoringCase() async {
        let books = ["Dune", "Middlemarch", "Dubliners"].map { Book(id: UUID(), title: $0, author: "") }
        let model = LibraryModel(service: StubBookService(result: .success(books)))
        await model.load()

        model.searchText = "du"

        #expect(model.visibleBooks.map(\.title) == ["Dune", "Dubliners"])
    }
}

SwitchableBookService is a few lines of test-only code that returns whatever the test sets next:

final class SwitchableBookService: BookService, @unchecked Sendable {
    // Test-only: each test owns its own instance.
    var next: Result<[Book], Error> = .success([])
    func fetchBooks() async throws -> [Book] { try next.get() }
}

For code that reports through a callback or delegate rather than returning a value, use confirmation, which fails the test if the event doesn’t happen the expected number of times:

@Test func savingNotifiesObserverOnce() async {
    let store = DraftStore()
    await confirmation(expectedCount: 1) { saved in
        store.onSave = { _ in saved() }
        await store.save(Draft(text: "Hello"))
    }
}

Test doubles: stubs, fakes and spies

The test double for a protocol-based service can be tiny. A stub returns canned answers; a fake has a working in-memory implementation; a spy records what it was asked to do.

final class SpyAnalytics: AnalyticsService, @unchecked Sendable {
    // Test-only: accessed from one test at a time.
    private(set) var events: [String] = []
    func track(_ name: String) { events.append(name) }
}

@MainActor
@Test func checkoutRecordsPurchaseEvent() async throws {
    let analytics = SpyAnalytics()
    let checkout = CheckoutModel(payments: StubPayments(result: .success), analytics: analytics)

    try await checkout.pay()

    #expect(analytics.events == ["purchase_completed"])
}

You rarely need a mocking library in Swift. Small hand-written doubles are easier to read, and if a protocol needs a large double, that’s a sign the protocol is doing too much.

For SwiftData, give tests an in-memory ModelContainer so each test starts clean; the SwiftData guide shows the configuration.

Traits: tags, conditions and time limits

Traits customise how tests run:

extension Tag {
    @Tag static var networking: Self
    @Tag static var slow: Self
}

@Test(.tags(.networking, .slow), .timeLimit(.minutes(1)))
func liveAPIStillMatchesOurModels() async throws {
    // Runs against a staging server: useful nightly, too slow for every commit.
}

@Test(.disabled("Waiting on the new receipts endpoint"), .bug("https://example.com/issues/212"))
func refundsAppearInHistory() async throws { }

Tags let you run subsets from Xcode’s test navigator or a test plan, and .disabled with a reason is better than commenting a test out, because it stays visible in results.

Swift Testing runs tests in parallel by default. That’s fast, and it also flushes out tests that secretly depend on shared state such as a singleton or UserDefaults.standard. If a test fails only when run with others, that’s usually the cause; inject the dependency rather than serialising the suite.

Where XCTest still fits

Swift Testing covers unit and integration tests. XCTest is still the framework for:

Both frameworks can live in the same test target, so there’s no need for a big-bang migration. Swift 6.4 also adds interoperability between the two, according to Apple’s What’s new in Swift, which makes moving gradually easier still.

Keep UI tests few and focused on journeys that would be a disaster to break. Give the important elements stable accessibility identifiers so tests don’t depend on display text that copywriters will change:

// In the app
Button("Pay now") { ... }
    .accessibilityIdentifier("checkout.pay")

// In the UI test
@MainActor
func testGuestCanCompleteCheckout() throws {
    let app = XCUIApplication()
    app.launchArguments = ["-uiTesting", "-stubPayments"]
    app.launch()

    app.buttons["product.add"].firstMatch.tap()
    app.buttons["basket"].tap()
    app.buttons["checkout.pay"].tap()

    XCTAssertTrue(app.staticTexts["checkout.confirmation"].waitForExistence(timeout: 5))
}

The launch arguments let the app swap in stub services, so the UI test doesn’t depend on a real payment provider or network.

Running tests on every change

Tests only protect you if they run. Set up continuous integration so every pull request builds the app and runs the suite. Xcode Cloud is built into Xcode and App Store Connect, and the Apple Developer Program includes 25 compute hours a month. GitHub Actions and other services with macOS runners work too.

A practical setup for a small team:

Key takeaways

An app with a good test suite is cheaper to own, because every future change carries less risk. If you’d like help adding tests to an app that has none, or setting up CI, let’s talk.

Written by Lee Johnson. Published 30 October 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project