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.
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:@Testfunctions,#expectfor checks that continue,#requirefor checks that stop the test,arguments:for parameterized tests, andasynctests thatawaitdirectly. - 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 testin 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):
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 ofsleep. - 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.