On this page 9 sections
What to test, and what not to
A test suite is code you maintain, so spend it where it pays back:
- Business rules. Prices, totals, VAT, validation, eligibility, date calculations. These are where bugs cost money and where tests are cheapest to write.
- State changes in your models. What happens on load, on failure, on retry, when the user filters or deletes. If you’ve structured the app with observable models and injected services, as in SwiftUI state and app architecture, these tests need no UI and run in milliseconds.
- Parsing and mapping. Decoding server responses, including the awkward ones: missing fields, nulls, unexpected values.
- A few critical journeys end to end. Sign in, the main purchase or booking flow, onboarding. These are UI tests, and a handful is enough.
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:
- UI tests with
XCUIApplication, including the accessibility audits in Accessible iOS apps in SwiftUI; - performance tests with
measure(metrics:), such as the launch test in Finding and fixing iOS performance problems; - existing suites you haven’t migrated.
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:
- every pull request: unit and model tests, plus the launch performance test;
- nightly: UI tests across two or three device sizes and the oldest supported iOS version, plus tagged slow tests;
- before each release: the full suite and a manual run-through of key journeys on real devices.
Key takeaways
- Test business rules, model state changes and parsing first; they’re cheap and catch costly bugs.
- Swift Testing’s
@Test,#expect,#requireand parameterised arguments make tests short and failures clear. - Inject services through protocols so tests use small hand-written doubles.
- Keep XCTest for UI and performance tests, and keep UI tests few and stable.
- Run the suite on every change with CI, and the slow parts nightly.
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.