Testing the Landmarks App with Dependency Injection
In the previous post, we wrote integration tests for the Vapor backend - booting a real server, making real HTTP requests, and verifying every endpoint. That covers the server. Now it's time to flip to the client side and test the iOS app itself.
The good news is that the closure-based dependency injection pattern from Post 3 makes this almost trivially easy. Every service is a struct with closure properties. Swap the closures, and you control exactly what happens during a test. No network, no server, no flaky timing issues. Just pure, deterministic Swift.
To make things concrete, we'll add a new feature - Reservations - following the same patterns we've used throughout the series. Then we'll test it from every angle: stores, services, mapping, and full integration flows.
You can find the complete project on GitHub.
The Reservation Domain Model
Users should be able to reserve visits to landmarks. Following the domain model pattern from Post 2, we start with a clean, validated domain model that views can use directly:
struct Reservation: DomainModel, Identifiable, Cacheable {
let id: UUID
let landmark: Landmark
let date: Date
let partySize: Int
let status: Status
enum Status: String, Codable, CaseIterable {
case pending
case confirmed
case cancelled
}
var cacheId: String { id.uuidString }
static var cacheIdentifier: String { "reservations" }
}
Every field is non-optional. The Status enum covers the three states a reservation can be in. The Cacheable conformance plugs into the tiered caching system from Post 4, so reservations get memory and disk caching for free.
Notice that the landmark property is a full Landmark domain model, not just an ID string. When a view displays a reservation, it needs the landmark's name, image, and category. Embedding the full model means views don't need to do a second lookup.
We also need some sample data for previews and tests:
extension Reservation {
static var sample: Reservation {
Reservation(
id: UUID(),
landmark: Landmark.sampleData[0],
date: Date().addingTimeInterval(86400 * 7),
partySize: 4,
status: .confirmed
)
}
static var sampleList: [Reservation] {
[
Reservation(
id: UUID(),
landmark: Landmark.sampleData[0],
date: Date().addingTimeInterval(86400 * 7),
partySize: 2,
status: .confirmed
),
Reservation(
id: UUID(),
landmark: Landmark.sampleData[1],
date: Date().addingTimeInterval(86400 * 14),
partySize: 6,
status: .pending
),
Reservation(
id: UUID(),
landmark: Landmark.sampleData[2],
date: Date().addingTimeInterval(86400 * 3),
partySize: 1,
status: .cancelled
),
]
}
}
The ReservationStore
The store holds observable state, exactly like LandmarkStore from Post 3. Views observe it directly - no view model needed:
@MainActor
@Observable
final class ReservationStore {
private(set) var reservations: [Reservation] = []
private(set) var loadingState: LoadingState<[Reservation]> = .idle
var activeReservations: [Reservation] {
reservations.filter { $0.status != .cancelled }
}
var pendingReservations: [Reservation] {
reservations.filter { $0.status == .pending }
}
var confirmedReservations: [Reservation] {
reservations.filter { $0.status == .confirmed }
}
func addReservation(_ reservation: Reservation) {
reservations.append(reservation)
loadingState = .loaded(reservations)
}
func removeReservation(id: UUID) {
reservations.removeAll { $0.id == id }
loadingState = .loaded(reservations)
}
func setReservations(_ reservations: [Reservation]) {
self.reservations = reservations
loadingState = .loaded(reservations)
}
func setLoading() {
loadingState = .loading
}
func setError(_ error: Error) {
loadingState = .failed(error)
}
}
The computed properties - activeReservations, pendingReservations, confirmedReservations - derive from the source data and stay consistent automatically. Views can use them directly in ForEach or List without any filtering logic cluttering the view body.
The private(set) on the stored properties enforces that only the store's own methods can mutate state. Views read, the store writes. This keeps state changes predictable and easy to trace during debugging.
The ReservationService
Following the closure-based pattern from Post 3, the service is a struct where each operation is a swappable closure:
struct ReservationService: Sendable {
let store: ReservationStore
var createReservation: @Sendable (Landmark, Date, Int) async throws -> Reservation
var fetchReservations: @Sendable () async throws -> Void
var cancelReservation: @Sendable (UUID) async throws -> Void
}
The closures are the key to testability. In production, they make network calls. In tests, they do whatever you want - return canned data, throw errors, track call counts, or just do nothing.
The Live Implementation
The live version hits the real backend we built in Post 5:
extension ReservationService {
static func live(
client: NetworkClient,
baseURL: URL
) -> ReservationService {
let store = ReservationStore()
return ReservationService(
store: store,
createReservation: { landmark, date, partySize in
let url = baseURL.appendingPathComponent("reservations")
let body = CreateReservationRequest(
landmarkId: landmark.id.uuidString,
date: ISO8601DateFormatter().string(from: date),
partySize: partySize
)
let apiModel = try await client.send(
ReservationApiModel.self,
to: url,
body: body
)
guard let reservation = apiModel.domainModel(landmarks: [landmark]) else {
throw ReservationError.invalidResponse
}
await store.addReservation(reservation)
return reservation
},
fetchReservations: {
await store.setLoading()
let url = baseURL.appendingPathComponent("reservations")
let apiModels = try await client.fetch(
[ReservationApiModel].self,
from: url
)
let reservations = apiModels.compactMap {
$0.domainModel(landmarks: Landmark.sampleData)
}
await store.setReservations(reservations)
},
cancelReservation: { id in
let url = baseURL.appendingPathComponent("reservations/\(id)")
_ = try await client.delete(url)
await store.removeReservation(id: id)
}
)
}
}
enum ReservationError: Error, LocalizedError {
case invalidResponse
case notFound
var errorDescription: String? {
switch self {
case .invalidResponse: "The server returned an invalid reservation."
case .notFound: "Reservation not found."
}
}
}
The Mock Implementation
This is where testing gets interesting. The mock doesn't just return canned data - it maintains real state. When you create a reservation, it appears in the store. When you cancel it, it disappears. The mock behaves like a real backend, which means your tests verify actual behavior rather than just checking that closures were called:
extension ReservationService {
static func mock(store: ReservationStore? = nil) -> ReservationService {
let store = store ?? ReservationStore()
let mockStore = MockReservationStore()
return ReservationService(
store: store,
createReservation: { landmark, date, partySize in
try await Task.sleep(for: .milliseconds(100))
let reservation = mockStore.createReservation(
landmark: landmark,
date: date,
partySize: partySize
)
await store.addReservation(reservation)
return reservation
},
fetchReservations: {
await store.setLoading()
try await Task.sleep(for: .milliseconds(200))
await store.setReservations(mockStore.reservations)
},
cancelReservation: { id in
try await Task.sleep(for: .milliseconds(100))
mockStore.cancel(id: id)
await store.removeReservation(id: id)
}
)
}
}
Preview and Unimplemented
The preview variant pre-populates with sample data for instant SwiftUI previews:
extension ReservationService {
static var preview: ReservationService {
let store = ReservationStore()
Task { @MainActor in
store.setReservations(Reservation.sampleList)
}
return ReservationService(
store: store,
createReservation: { landmark, date, partySize in
let reservation = Reservation(
id: UUID(),
landmark: landmark,
date: date,
partySize: partySize,
status: .confirmed
)
await store.addReservation(reservation)
return reservation
},
fetchReservations: { },
cancelReservation: { id in
await store.removeReservation(id: id)
}
)
}
}
The unimplemented variant crashes immediately if any closure is called. This is the default you set in your environment so that unexpected service calls surface as loud failures instead of silent bugs:
extension ReservationService {
static var unimplemented: ReservationService {
ReservationService(
store: ReservationStore(),
createReservation: { _, _, _ in
fatalError("createReservation not implemented")
},
fetchReservations: {
fatalError("fetchReservations not implemented")
},
cancelReservation: { _ in
fatalError("cancelReservation not implemented")
}
)
}
}
The MockReservationStore
The MockReservationStore is the piece that makes stateful mocks possible. It's not an @Observable class - it's a plain class that tracks state for testing purposes. The real ReservationStore is what views observe. This mock backs the service closures:
final class MockReservationStore {
private(set) var reservations: [Reservation] = []
func createReservation(
landmark: Landmark,
date: Date,
partySize: Int
) -> Reservation {
let reservation = Reservation(
id: UUID(),
landmark: landmark,
date: date,
partySize: partySize,
status: .confirmed
)
reservations.append(reservation)
return reservation
}
func cancel(id: UUID) {
reservations.removeAll { $0.id == id }
}
func fetchAll() -> [Reservation] {
reservations
}
func reservation(for id: UUID) -> Reservation? {
reservations.first { $0.id == id }
}
}
The key insight here is that the mock actually works. If you call createReservation, the reservation exists in the mock's array. If you then call cancel, it's gone. This means your tests can verify real sequences of operations - reserve, then cancel, then verify the list is empty - without any server.
This is different from mocks that just return hardcoded values. Hardcoded mocks test that your code calls the right methods, but they don't test that the state flows correctly across multiple operations. Stateful mocks test both.
The ReservationApiModel
Following the API model pattern from Post 2, the API model matches what the server sends. Everything is optional or stringly-typed because you can't trust network data:
struct ReservationApiModel: ApiModel, Identifiable {
let id: String
let landmarkId: String?
let date: String?
let partySize: Int?
let status: String?
}
struct CreateReservationRequest: Codable {
let landmarkId: String
let date: String
let partySize: Int
}
The mapping layer validates everything and produces a clean domain model or nil:
extension ReservationApiModel {
func domainModel(landmarks: [Landmark]) -> Reservation? {
guard let landmarkId = landmarkId,
let landmarkUUID = UUID(uuidString: landmarkId),
let landmark = landmarks.first(where: { $0.id == landmarkUUID }),
let dateString = date,
let parsedDate = ISO8601DateFormatter().string(from: dateString),
let partySize = partySize,
partySize > 0,
let statusString = status,
let status = Reservation.Status(rawValue: statusString),
let reservationId = UUID(uuidString: id) else {
return nil
}
return Reservation(
id: reservationId,
landmark: landmark,
date: parsedDate,
partySize: partySize,
status: status
)
}
}
private extension ISO8601DateFormatter {
static func string(from string: String) -> Date? {
let formatter = ISO8601DateFormatter()
return formatter.date(from: string)
}
}
The domainModel(landmarks:) method takes a list of known landmarks and matches by ID. If the landmark doesn't exist in our local data, the reservation is filtered out. The compactMap pattern from Post 2 handles this cleanly - invalid records silently disappear instead of crashing the app.
Notice the partySize > 0 check. The server might accept zero or negative values, but our domain model says that doesn't make sense. Validation at the mapping boundary keeps bad data from reaching views.
Unit Testing the Store
Now the fun part. Let's test the ReservationStore in isolation. These tests verify that state updates work correctly without any service or network involvement:
import XCTest
@testable import Landmarks
final class ReservationStoreTests: XCTestCase {
@MainActor
func testInitialState() async {
let store = ReservationStore()
XCTAssertTrue(store.reservations.isEmpty)
XCTAssertTrue(store.activeReservations.isEmpty)
XCTAssertTrue(store.pendingReservations.isEmpty)
XCTAssertTrue(store.confirmedReservations.isEmpty)
if case .idle = store.loadingState {
// Expected
} else {
XCTFail("Expected idle loading state")
}
}
@MainActor
func testAddReservation() async {
let store = ReservationStore()
let reservation = Reservation.sample
store.addReservation(reservation)
XCTAssertEqual(store.reservations.count, 1)
XCTAssertEqual(store.reservations.first?.id, reservation.id)
}
@MainActor
func testAddMultipleReservations() async {
let store = ReservationStore()
for reservation in Reservation.sampleList {
store.addReservation(reservation)
}
XCTAssertEqual(store.reservations.count, 3)
}
@MainActor
func testRemoveReservation() async {
let store = ReservationStore()
let reservation = Reservation.sample
store.addReservation(reservation)
XCTAssertEqual(store.reservations.count, 1)
store.removeReservation(id: reservation.id)
XCTAssertTrue(store.reservations.isEmpty)
}
@MainActor
func testRemoveNonexistentReservation() async {
let store = ReservationStore()
let reservation = Reservation.sample
store.addReservation(reservation)
store.removeReservation(id: UUID()) // different ID
XCTAssertEqual(store.reservations.count, 1)
}
@MainActor
func testActiveReservationsExcludesCancelled() async {
let store = ReservationStore()
let confirmed = Reservation(
id: UUID(),
landmark: Landmark.sampleData[0],
date: Date(),
partySize: 2,
status: .confirmed
)
let cancelled = Reservation(
id: UUID(),
landmark: Landmark.sampleData[1],
date: Date(),
partySize: 4,
status: .cancelled
)
let pending = Reservation(
id: UUID(),
landmark: Landmark.sampleData[2],
date: Date(),
partySize: 1,
status: .pending
)
store.addReservation(confirmed)
store.addReservation(cancelled)
store.addReservation(pending)
XCTAssertEqual(store.reservations.count, 3)
XCTAssertEqual(store.activeReservations.count, 2)
XCTAssertEqual(store.pendingReservations.count, 1)
XCTAssertEqual(store.confirmedReservations.count, 1)
}
@MainActor
func testSetReservationsReplacesAll() async {
let store = ReservationStore()
store.addReservation(Reservation.sample)
XCTAssertEqual(store.reservations.count, 1)
store.setReservations(Reservation.sampleList)
XCTAssertEqual(store.reservations.count, 3)
}
@MainActor
func testSetLoadingUpdatesState() async {
let store = ReservationStore()
store.setLoading()
if case .loading = store.loadingState {
// Expected
} else {
XCTFail("Expected loading state")
}
}
@MainActor
func testSetErrorUpdatesState() async {
let store = ReservationStore()
let error = ReservationError.notFound
store.setError(error)
if case .failed = store.loadingState {
// Expected
} else {
XCTFail("Expected failed loading state")
}
}
}
These tests are fast - no async waits, no network delays. They verify every store operation and computed property. If someone changes the filter logic for activeReservations, these tests catch it immediately.
Testing with .unimplemented
The .unimplemented service variant is a testing tool that deserves its own spotlight. It catches unexpected service calls - if a view or test accidentally triggers a service method you didn't anticipate, the test crashes with a clear message instead of silently succeeding:
final class UnimplementedServiceTests: XCTestCase {
func testUnimplementedServiceExists() {
let service = ReservationService.unimplemented
// The service itself can be created without issue
XCTAssertNotNil(service.store)
}
func testUnimplementedCreateCrashes() async {
let service = ReservationService.unimplemented
// If you uncomment this line, the test crashes with
// "createReservation not implemented" - exactly what we want.
//
// let _ = try await service.createReservation(
// Landmark.sampleData[0], Date(), 4
// )
//
// This is useful when writing tests for views that should
// NOT trigger certain service calls. Start with .unimplemented,
// then override only the closures the view should actually use.
}
}
The real power of .unimplemented shows up when you test views or flows that should only call specific service methods. Start with .unimplemented as your base, then override just the closures you expect to be called:
func testReservationListOnlyFetches() async throws {
let store = ReservationStore()
var fetchCalled = false
// Start with unimplemented - any unexpected call crashes
var service = ReservationService.unimplemented
// Override only what we expect to be called
service = ReservationService(
store: store,
createReservation: { _, _, _ in
fatalError("List view should not create reservations")
},
fetchReservations: {
fetchCalled = true
await store.setReservations(Reservation.sampleList)
},
cancelReservation: { _ in
fatalError("List view should not cancel from initial load")
}
)
try await service.fetchReservations()
XCTAssertTrue(fetchCalled)
XCTAssertEqual(store.reservations.count, 3)
}
If someone later adds code that accidentally calls createReservation during the list load, the test crashes immediately. That's the safety net - it turns forgotten mocks into loud failures.
Testing the Mapping Layer
The mapping from API models to domain models is where bad data gets filtered out. These tests verify that valid data maps correctly and invalid data returns nil:
final class ReservationMappingTests: XCTestCase {
let sampleLandmarks = Landmark.sampleData
func testValidReservationMaps() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: ISO8601DateFormatter().string(from: Date()),
partySize: 4,
status: "confirmed"
)
let reservation = apiModel.domainModel(landmarks: sampleLandmarks)
XCTAssertNotNil(reservation)
XCTAssertEqual(reservation?.landmark.id, sampleLandmarks[0].id)
XCTAssertEqual(reservation?.partySize, 4)
XCTAssertEqual(reservation?.status, .confirmed)
}
func testMissingLandmarkIdReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: nil,
date: ISO8601DateFormatter().string(from: Date()),
partySize: 2,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testInvalidLandmarkIdReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: "not-a-uuid",
date: ISO8601DateFormatter().string(from: Date()),
partySize: 2,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testUnknownLandmarkReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: UUID().uuidString, // valid UUID but not in our list
date: ISO8601DateFormatter().string(from: Date()),
partySize: 2,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testMissingDateReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: nil,
partySize: 4,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testInvalidDateReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: "not-a-date",
partySize: 4,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testZeroPartySizeReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: ISO8601DateFormatter().string(from: Date()),
partySize: 0,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testNegativePartySizeReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: ISO8601DateFormatter().string(from: Date()),
partySize: -3,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testMissingStatusReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: ISO8601DateFormatter().string(from: Date()),
partySize: 2,
status: nil
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testInvalidStatusReturnsNil() {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: ISO8601DateFormatter().string(from: Date()),
partySize: 2,
status: "expired" // not a valid status
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
func testAllValidStatusesMaps() {
for status in Reservation.Status.allCases {
let apiModel = ReservationApiModel(
id: UUID().uuidString,
landmarkId: sampleLandmarks[0].id.uuidString,
date: ISO8601DateFormatter().string(from: Date()),
partySize: 1,
status: status.rawValue
)
let reservation = apiModel.domainModel(landmarks: sampleLandmarks)
XCTAssertNotNil(reservation, "Status \(status.rawValue) should map successfully")
XCTAssertEqual(reservation?.status, status)
}
}
func testInvalidReservationIdReturnsNil() {
let apiModel = ReservationApiModel(
id: "not-a-uuid",
landmarkId: sampleLandmarks[0].id.uuidString,
date: ISO8601DateFormatter().string(from: Date()),
partySize: 2,
status: "confirmed"
)
XCTAssertNil(apiModel.domainModel(landmarks: sampleLandmarks))
}
}
This might look like a lot of tests for a mapping function, but every one of them exists because of a real failure mode. Servers send bad data. APIs evolve. New statuses get added. These tests verify that the mapping layer handles every edge case without crashing or showing broken UI.
The testAllValidStatusesMaps test is particularly useful. It iterates over CaseIterable to verify every status value works. If someone adds a new status to the enum but forgets to handle it in the mapping, this test catches it.
Integration Testing with Mock Services
Now we combine everything. These tests verify complete user flows - creating reservations, fetching lists, cancelling - using the mock service that maintains real state:
final class ReservationServiceTests: XCTestCase {
@MainActor
func testCreateReservation() async throws {
let store = ReservationStore()
let service = ReservationService.mock(store: store)
let reservation = try await service.createReservation(
Landmark.sampleData[0],
Date(),
4
)
XCTAssertEqual(reservation.landmark.id, Landmark.sampleData[0].id)
XCTAssertEqual(reservation.partySize, 4)
XCTAssertEqual(reservation.status, .confirmed)
XCTAssertEqual(store.reservations.count, 1)
}
@MainActor
func testCreateMultipleReservations() async throws {
let store = ReservationStore()
let service = ReservationService.mock(store: store)
let first = try await service.createReservation(
Landmark.sampleData[0], Date(), 2
)
let second = try await service.createReservation(
Landmark.sampleData[1], Date(), 6
)
XCTAssertEqual(store.reservations.count, 2)
XCTAssertNotEqual(first.id, second.id)
XCTAssertEqual(first.landmark.id, Landmark.sampleData[0].id)
XCTAssertEqual(second.landmark.id, Landmark.sampleData[1].id)
}
@MainActor
func testCancelReservation() async throws {
let store = ReservationStore()
let service = ReservationService.mock(store: store)
let reservation = try await service.createReservation(
Landmark.sampleData[0], Date(), 4
)
XCTAssertEqual(store.reservations.count, 1)
try await service.cancelReservation(reservation.id)
XCTAssertEqual(store.reservations.count, 0)
}
@MainActor
func testReserveAndVerifyFlow() async throws {
let store = ReservationStore()
let service = ReservationService.mock(store: store)
// Create three reservations
let r1 = try await service.createReservation(
Landmark.sampleData[0], Date(), 2
)
let r2 = try await service.createReservation(
Landmark.sampleData[1], Date(), 4
)
let r3 = try await service.createReservation(
Landmark.sampleData[2], Date(), 1
)
XCTAssertEqual(store.reservations.count, 3)
XCTAssertEqual(store.activeReservations.count, 3)
// Cancel the middle one
try await service.cancelReservation(r2.id)
XCTAssertEqual(store.reservations.count, 2)
XCTAssertTrue(store.reservations.contains { $0.id == r1.id })
XCTAssertFalse(store.reservations.contains { $0.id == r2.id })
XCTAssertTrue(store.reservations.contains { $0.id == r3.id })
}
@MainActor
func testFetchReservations() async throws {
let store = ReservationStore()
let service = ReservationService.mock(store: store)
// Create some reservations first
_ = try await service.createReservation(
Landmark.sampleData[0], Date(), 2
)
_ = try await service.createReservation(
Landmark.sampleData[1], Date(), 3
)
// Create a fresh store and service to simulate app restart
let freshStore = ReservationStore()
let freshService = ReservationService.mock(store: freshStore)
try await freshService.fetchReservations()
// The fresh store should now have reservations
// (In a real app these would come from the server;
// the mock fetches from its internal state)
XCTAssertNotNil(freshStore.loadingState.value)
}
@MainActor
func testCancelNonexistentReservation() async throws {
let store = ReservationStore()
let service = ReservationService.mock(store: store)
// Cancelling a reservation that doesn't exist should not crash
try await service.cancelReservation(UUID())
XCTAssertEqual(store.reservations.count, 0)
}
@MainActor
func testCreateReservationForSameLandmarkTwice() async throws {
let store = ReservationStore()
let service = ReservationService.mock(store: store)
let landmark = Landmark.sampleData[0]
let first = try await service.createReservation(landmark, Date(), 2)
let second = try await service.createReservation(landmark, Date(), 4)
XCTAssertEqual(store.reservations.count, 2)
XCTAssertNotEqual(first.id, second.id)
XCTAssertEqual(first.landmark.id, second.landmark.id)
}
}
The testReserveAndVerifyFlow test is the most interesting. It creates three reservations, cancels one, and verifies that exactly the right reservations remain. This is a real user flow - browse landmarks, reserve a few, change your mind about one. The test proves the whole chain works: service closures update the mock store, which feeds state to the observable store, which views would observe.
Notice we don't need to start a server, configure a database, or wait for network responses. The mock service gives us full-stack confidence with unit-test speed.
Testing with Custom Service Closures
Sometimes you need more control than the standard mock provides. You can build a service with custom closures that track exactly what was called:
final class CustomServiceTests: XCTestCase {
@MainActor
func testServiceCallOrder() async throws {
let store = ReservationStore()
var callLog: [String] = []
let service = ReservationService(
store: store,
createReservation: { landmark, date, partySize in
callLog.append("create:\(landmark.name)")
let reservation = Reservation(
id: UUID(),
landmark: landmark,
date: date,
partySize: partySize,
status: .confirmed
)
store.addReservation(reservation)
return reservation
},
fetchReservations: {
callLog.append("fetch")
store.setReservations(store.reservations)
},
cancelReservation: { id in
callLog.append("cancel:\(id)")
store.removeReservation(id: id)
}
)
let r = try await service.createReservation(
Landmark.sampleData[0], Date(), 2
)
try await service.fetchReservations()
try await service.cancelReservation(r.id)
XCTAssertEqual(callLog.count, 3)
XCTAssertTrue(callLog[0].hasPrefix("create:"))
XCTAssertEqual(callLog[1], "fetch")
XCTAssertTrue(callLog[2].hasPrefix("cancel:"))
}
@MainActor
func testServiceErrorHandling() async throws {
let store = ReservationStore()
let service = ReservationService(
store: store,
createReservation: { _, _, _ in
throw ReservationError.invalidResponse
},
fetchReservations: {
store.setError(ReservationError.notFound)
},
cancelReservation: { _ in
throw ReservationError.notFound
}
)
// Create should throw
do {
_ = try await service.createReservation(
Landmark.sampleData[0], Date(), 2
)
XCTFail("Expected error")
} catch {
XCTAssertTrue(error is ReservationError)
}
// Fetch should set error state on the store
try await service.fetchReservations()
if case .failed = store.loadingState {
// Expected
} else {
XCTFail("Expected failed loading state")
}
// Cancel should throw
do {
try await service.cancelReservation(UUID())
XCTFail("Expected error")
} catch {
XCTAssertTrue(error is ReservationError)
}
}
}
The callLog pattern is particularly useful for verifying that your views call services in the right order. If a view should fetch reservations before creating one (to check for duplicates, for example), the call log proves it.
Error handling tests are just as important. The testServiceErrorHandling test verifies that errors propagate correctly - the store enters a failed state, and throwing closures bubble errors up to the caller. Views that use do/catch around service calls will handle these correctly.
Updating Services.swift
The final piece is adding the reservation service to the Services container from Post 3:
struct Services: Sendable {
let landmarks: LandmarkService
let reservations: ReservationService
let analytics: AnalyticsService
}
extension Services {
static func live(baseURL: URL) -> Services {
let networkClient = NetworkClient.live
let cache = TieredCache(
memory: MemoryCache(maxItemsPerType: 100),
disk: DiskCache()
)
return Services(
landmarks: .live(
client: networkClient,
baseURL: baseURL,
cache: cache
),
reservations: .live(
client: networkClient,
baseURL: baseURL
),
analytics: .live
)
}
static var mock: Services {
Services(
landmarks: .mock,
reservations: .mock(),
analytics: .live
)
}
static var preview: Services {
Services(
landmarks: .preview,
reservations: .preview,
analytics: .preview
)
}
static var unimplemented: Services {
Services(
landmarks: .unimplemented,
reservations: .unimplemented,
analytics: .unimplemented
)
}
}
Add the environment key and view modifier:
extension EnvironmentValues {
@Entry var reservationService: ReservationService = .unimplemented
}
extension View {
func services(_ services: Services) -> some View {
environment(\.services, services)
.environment(\.landmarkService, services.landmarks)
.environment(\.reservationService, services.reservations)
.environment(\.analyticsService, services.analytics)
}
}
The default is .unimplemented, just like the landmark service. Inject once at the app root, and every view in the hierarchy has access.
Wrapping Up
Testing an iOS app doesn't need to be painful. The closure-based DI pattern from Post 3 makes it straightforward:
Stores are testable in isolation. They're plain @Observable classes with simple methods. No network, no dependencies, no setup.
Services are testable with swappable closures. The .mock variant maintains real state. The .unimplemented variant catches unexpected calls. Custom closures let you track call order and inject errors.
Mapping is testable with static data. Feed an API model in, check whether a domain model comes out. Every edge case gets its own test.
Integration flows are testable end-to-end. Create a mock service, run a full user flow, and verify the state at each step. No server needed.
The key insight is the MockReservationStore - a stateful mock that actually works like a real backend. When you create a reservation, it exists. When you cancel it, it's gone. This lets you test real sequences of operations rather than just verifying method calls.
Here's where we are in the series:
Post 1: Navigation established type-safe navigation with a Screen enum and centralized DestinationContent.
Post 2: API vs Domain Models separated network concerns from business logic with .domainModel mapping at the boundary.
Post 3: Dependency Injection replaced protocols and view models with closure-based services and observable stores.
Post 4: Caching added memory and disk caching with LRU eviction and flexible fetch policies.
Post 5: Vapor Backend built the server with Fluent models, response types, and RESTful routes.
Post 6: Full Integration wired every layer together into a complete client-server app.
Post 7: Deployment containerized the server with Docker, deployed to AWS with ECS Fargate, and automated everything with GitHub Actions.
Post 8: Server Tests added integration tests that boot a real server and verify every endpoint.
Post 9 (this one) tested the iOS app with closure-based dependency injection, stateful mocks, and no server required.
In the next post, we'll use Grantiva to write YAML-based visual regression tests. The MockReservationStore we built here is what makes it possible — Grantiva taps buttons that update local state as if API responses came back, captures screenshots at every step, and diffs them pixel-by-pixel to catch visual regressions. No server needed.