Most iOS testing advice is either "aim for 80% coverage" or "just write UI tests", and both produce suites that are slow, brittle and quietly ignored. This lesson is what is actually worth testing in a SwiftUI app, in the order the return diminishes — and the techniques for the parts that are genuinely awkward.
What to test, in order
Pure rules first. Pricing, validation, the cart reducer, date formatting. They cost nothing to test, they run in microseconds, and they are where a bug is most expensive.
The networking layer next, through a real URLSession against a
stubbed transport, so the assertions are about the bytes you would actually send.
Then stores and view models against hand-written doubles. This is where behaviour lives — did signing out clear the token, does a cancelled payment stay silent.
Views, barely. Snapshot tests are brittle and UI tests are slow. The demo app has 130 tests and no UI test target at all, and the reason is worth stating: its three sibling web frontends are covered by Playwright suites that drive the real flows, and duplicating that here would mostly be testing SwiftUI rather than this app.
Pure rules
func testADifferentToppingSetIsADifferentLine() {
// Without this, a plain pepperoni and a pepperoni with extra cheese would collapse into one
// line and the customer would be charged for the wrong pizza.
var state = CartReducer.reduce(.initial, .add(item(toppings: [])))
state = CartReducer.reduce(state, .add(item(toppings: [SampleData.toppings[4]])))
XCTAssertEqual(state.items.count, 2)
}
No view, no async, no main actor, no setup. Fifteen tests cover the cart's rules and run in about a millisecond together.
The ones worth writing carefully are the negative cases and the properties rather than the examples. That topping order does not make a different pizza. That reducing does not mutate the state it was given. That dropping a quantity to zero removes the line. Those are the rules that are hard to hold in your head and easy to break in a refactor.
Money in particular
func testRoundingIsHalfUpRatherThanBankers() {
// Swift's default `.rounded()` is `.toNearestOrEven`, which would give 0.12 here. A price
// is rounded half UP, which is what a customer expects and what a till does.
XCTAssertEqual(Money.rounded(0.125), 0.13)
XCTAssertEqual(Money.rounded(0.135), 0.14)
}
Two assertions that encode a decision somebody made deliberately. Without the test, a future
refactor to value.rounded() looks harmless and changes what customers are charged by a
cent in a way nobody would notice for months.
Testing the network for real
The usual advice is to hide URLSession behind a protocol and inject a fake. That
tests the code around the session and skips the session itself — so it cannot catch a wrong
HTTP method, a missing header, or a body that fails to encode, which are exactly the mistakes this
layer makes.
final class StubURLProtocol: URLProtocol {
/// Called with the outgoing request; returns the response and body to reply with.
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
/// A session wired to this protocol and nothing else.
static func makeSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [StubURLProtocol.self]
return URLSession(configuration: configuration)
}
URLProtocol sits underneath URLSession. The real session runs,
builds the real request, and this hands back a canned response. So a test can assert on what was
sent:
XCTAssertNil(
captured.value?.value(forHTTPHeaderField: "Authorization"),
"A public endpoint must not leak the token."
)
That is a security property, tested. The paired assertion checks the bearer token is attached when the endpoint asks for it, and together they prove the one flag in the whole networking layer behaves.
One rule, and it is not optional
override func tearDown() {
StubURLProtocol.handler = nil
session = nil
super.tearDown()
}
The handler is process-global. A stale closure left behind answers the next test's request, producing a failure that moves when the test order changes — which is the most expensive kind to chase, because it looks like flakiness rather than a bug.
Doubles, written by hand
A mocking framework would remove the boilerplate and add a dependency, a code-generation step and a layer between a failing test and the reason it failed. At this size the boilerplate is cheaper — and a double you wrote is one you can read when a test starts failing for a reason the framework does not explain.
final class CatalogRepositorySpy: CatalogRepository, @unchecked Sendable {
var products: [Product] = SampleData.products
var toppings: [Topping] = SampleData.toppings
var crusts: [Crust] = SampleData.crusts
var error: Error?
private(set) var productsCallCount = 0
Configurable results, and a record of what was asked for. Asserting on the call is what catches the bugs a return value cannot: a screen that fetches twice, a write that never happens, a request sent without authentication.
Testing async and main-actor code
This used to need expectations and timeouts. It no longer does — a test method can be
async and await the thing it is testing:
@MainActor
func testARejectedTokenIsDiscardedRatherThanLeftBehind() async {
let secureStore = InMemorySecureStore(seed: [.authToken: "expired.token"])
let repository = AuthRepositorySpy(currentUserResult: .failure(APIError.unauthorized))
let (store, _, tokenStore) = makeStore(repository: repository, secureStore: secureStore)
await store.restoreSession()
XCTAssertFalse(store.isAuthenticated)
let remaining = await tokenStore.currentToken()
XCTAssertNil(remaining)
}
Linear, no callbacks, no timeout to tune. The two awaits are different actors — the
store is main-actor isolated, the token store is an actor — and the test crosses both without
ceremony.
@MainActor on the class does not compile cleanly
The tempting spelling is @MainActor final class AuthStoreTests: XCTestCase, and it
produces a warning that becomes an error in Swift 6 — so effectively it does not
compile going forward:
// warning: main actor-isolated class 'AuthStoreTests' has different actor
// isolation from nonisolated superclass 'XCTestCase';
// this is an error in Swift 6
@MainActor
final class AuthStoreTests: XCTestCase {
}
XCTestCase is nonisolated and a subclass may not add isolation its superclass does
not have. Annotate each test method instead. It is more typing and it is more honest — the
isolation belongs to the work, not to the fixture.
Testing a debounce without sleeping for real
The cart writes 300 ms after the last change, and a test that waits 300 ms is a test that makes the suite slow. Inject the interval:
let store = CartStore(
repository: repository,
identifierStore: CartIdentifierStore(store: InMemoryKeyValueStore(seed: seed)),
menuStore: menu,
// Effectively no debounce, so a test does not have to sleep to observe a write.
persistDebounce: .milliseconds(1)
)
A timing value that is a constant is a timing value a test has to live with. Making it a constructor parameter with a production default costs nothing and turns an untestable behaviour into an ordinary one.
The most valuable test in that file asserts something does not happen:
XCTAssertEqual(repository.replaceCallCount, 0)
XCTAssertFalse(store.isHydrated)
Nothing is written before hydration — because writing an empty cart over a saved one is the worst bug this type can have, and it would only reproduce for customers who already had a basket.
Testing routes without a socket
Because an Endpoint is inert data, the whole routing layer is testable with plain
assertions:
func testMyOrdersCarriesPagingAndRequiresAuthentication() {
let endpoint = OrderEndpoints.myOrders(page: 2, size: 10)
XCTAssertEqual(url(endpoint), "http://localhost:8085/api/orders/mine?page=2&size=10")
XCTAssertTrue(endpoint.requiresAuthentication)
}
A path typo is caught by a test that never opens a socket. The version of this test that earns its place most is the one that loops:
for endpoint in endpoints {
XCTAssertTrue(endpoint.requiresAuthentication, "\(endpoint.path) must be authenticated")
XCTAssertTrue(endpoint.path.hasPrefix("/api/me/"), "\(endpoint.path) must be under /api/me")
}
Every profile route, asserted to be authenticated and to carry no user id in its path. That is a security property written as a test rather than as a comment — and a new endpoint added to the list is checked automatically, which a comment would not be.
Tests as documentation of a decision
The best tests in this suite are the ones whose names state a rule and whose failure messages explain it:
XCTAssertEqual(
repository.createCallCount, 0,
"Do not create a cart server-side just because someone opened the app."
)
Somebody who breaks that in two years gets the reasoning along with the failure, rather than a line number and a zero that was expected to be a zero.
Sample data that is not too tidy
Both the tests and the previews share one fixture file, and the data in it is deliberately awkward: a pizza with three sizes, a crust with a surcharge and two without, an address with a second line, a card with no expiry at all.
Fixtures that are too neat hide exactly the problems tests exist to catch. A
PaymentMethod whose brand and last four digits are always present never exercises the
branch that renders "Card •••• ????", and that branch is the one that will be hit by a real record
from a payment provider having a bad day.
Arrange, act, assert
Every test above has the same three-part shape with blank lines between them, and it is worth being deliberate about. The arrange block builds the world, the act block is one line, and the assertions follow. When the act block is more than a line or two, that is usually a signal the test is covering two things and should be two tests.
The other habit worth forming: one factory per suite for the fixture, with defaulted parameters,
so each test overrides only what it is actually about. makeStore(repository:) and
item(toppings:) in this suite both exist for that reason, and they are why the tests read
as statements of a rule rather than as setup with an assertion at the bottom.
What about UI tests?
XCUITest drives the real app through the accessibility layer. It is the only way to
test a full flow end to end, and it is slow, brittle, and prone to failing for reasons that have
nothing to do with your change.
The pragmatic position: a handful for the flows that would cost you money if they broke — sign in, add to cart, check out — and nothing else. And note the dependency, because it is a good argument for lesson 18: a UI test finds elements by accessibility identifier and label, so an app with good accessibility is an app that is straightforward to test, and one without is one where every test starts by adding identifiers.
Running them
xcodebuild test -project Pizza.xcodeproj -scheme Pizza \
-destination 'platform=iOS Simulator,name=iPhone 15'
Coverage is enabled in the scheme, and it is worth reading as a map of what is untested rather than as a number to maximise. In this app the views show near zero and that is the expected result, not a gap — everything worth asserting was extracted into something that does not need rendering, which was the point of the architecture in the first place.
Next
Tests tell you whether the logic is right. Previews tell you whether the screen is, and the next lesson is them — plus the linting and formatting configuration worth having.