A view model needs a service, which needs a network client. Dependency injection wires this graph and — crucially — lets you swap a real service for a fake in tests. Learn DI with protocols in Swift.
The key to testable iOS code is that a view model depends on a protocol describing what it needs, not a concrete class. You define the capability as a protocol, provide a real implementation for the app, and can substitute a fake in tests. This dependency inversion is the seam that makes the architecture testable — everything else follows from it.
// the CONTRACT — the view model depends on this, not a concrete type
protocol UserService {
func fetchUser(_ id: Int) async throws -> User
}
// real implementation for the app
struct APIUserService: UserService {
func fetchUser(_ id: Int) async throws -> User {
// ... real URLSession call (networking lesson)
}
}
@Observable
final class ProfileViewModel {
private let service: UserService // a protocol, not APIUserService
init(service: UserService) { self.service = service }
}
// depending on the protocol lets you inject a real OR fake service.The simplest, clearest form of DI is constructor injection: a type receives its dependencies as init parameters rather than creating them itself. This makes dependencies explicit, prevents hidden coupling, and — because you pass them in — lets a test pass a fake. Default parameter values keep call sites clean while still allowing substitution.
@Observable
final class ProfileViewModel {
private let service: UserService
// inject the dependency; a default keeps app call sites simple
init(service: UserService = APIUserService()) {
self.service = service
}
func load(id: Int) async { /* uses self.service */ }
}
// app: uses the default real service
let vm = ProfileViewModel()
// test: inject a fake (next topic)
let testVM = ProfileViewModel(service: FakeUserService())
// dependencies are explicit + swappable. No hidden 'new' inside the type.For dependencies many views share — a session, an analytics client — SwiftUI’s environment injects a value high in the tree that any view reads with @Environment, avoiding threading it through every initializer. Combined with constructor injection for view models, the environment handles app-wide services cleanly while keeping view models explicitly testable.
// make a service available app-wide via the environment
extension EnvironmentValues {
@Entry var userService: UserService = APIUserService()
}
struct RootView: View {
var body: some View {
HomeView()
.environment(\.userService, APIUserService()) // inject once
}
}
struct SomeView: View {
@Environment(\.userService) private var service // read anywhere below
var body: some View { Text("...") }
}
// environment for app-wide services; constructor injection for view models.The whole point of this indirection is testing. Because a view model takes a UserService protocol, a test constructs it with a fake that returns canned data or errors — no network, no flakiness, instant and deterministic. Every architectural choice in this course exists to make this substitution possible, which is exactly what the testing lesson exploits.
// a hand-written fake — no network, fully controlled
struct FakeUserService: UserService {
var result: Result<User, Error> = .success(User(id: 1, name: "Ada"))
func fetchUser(_ id: Int) async throws -> User { try result.get() }
}
// in a test: inject the fake, drive the view model, assert on its state
func testLoadSucceeds() async {
let vm = ProfileViewModel(service: FakeUserService())
await vm.load(id: 1)
// assert vm.user?.name == "Ada" (no network involved -> fast + deterministic)
}
// injecting a protocol is what makes fast, reliable unit tests possible.