Dependency Injection & Testability

iOS Development — Architecture & Data

Chapter 5 · Dependency Injection & Testability

Chapter 3's own APIClient is a real improvement over scattered networking code — but if a ViewModel creates its own APIClient internally, there's still no real way to test that ViewModel without hitting an actual real network. This chapter covers the fix: dependency injection.

The Real Problem

@Observable @MainActor final class TaskListViewModel { private let apiClient = APIClient() // created internally — a real, genuine testing dead end func refresh() async { tasks = (try? await apiClient.fetch([Task].self, from: tasksURL)) ?? [] } }
Why This Is a Real, Genuine Problem
Every test of refresh() would genuinely hit the real network — slow, flaky, and dependent on a real server actually being reachable and returning predictable data. There's no real way to substitute a fake, predictable response without changing TaskListViewModel's own source code.

The Fix: Depend on an Abstraction, Not a Concrete Type

Real dependency injection means a type receives its own dependencies from outside, rather than creating them itself — and depending on a real protocol, not the concrete APIClient, is what actually makes substituting a fake possible.

protocol APIClientProtocol { func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T } struct APIClient: APIClientProtocol { func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T { // the chapter 3 implementation, unchanged } }
TaskListViewModel.swift — Refactored with Initializer Injection
@Observable @MainActor final class TaskListViewModel { private let apiClient: APIClientProtocol init(apiClient: APIClientProtocol = APIClient()) { self.apiClient = apiClient } func refresh() async { tasks = (try? await apiClient.fetch([Task].self, from: tasksURL)) ?? [] } }
A Real, Genuinely Small Change With a Large Payoff
Real, ordinary app code needs no change at all — TaskListViewModel() still works exactly as before, thanks to the real default parameter value. But a test, or a SwiftUI Preview, can now pass in anything conforming to APIClientProtocol — including a fake that returns predictable data instantly, with no real network involved.

A Real Fake for Tests and Previews

struct FakeAPIClient: APIClientProtocol { func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T { let sample = [Task(title: "Sample Task", priority: 2)] return sample as! T // safe here: this fake is only ever used with [Task] } } // In a SwiftUI Preview: #Preview { TaskListView(viewModel: TaskListViewModel(apiClient: FakeAPIClient())) }
A Real, Practical Win Beyond Testing
This same fake makes SwiftUI Previews work reliably too — a Preview using the real APIClient() would either hang waiting on a real network call inside Xcode's own canvas, or show empty state. FakeAPIClient gives a Preview real, immediate, predictable sample data.

SwiftUI's Own Native DI: Custom @Environment Values

For app-wide shared services — reaching many unrelated screens, not just one ViewModel's own initializer — SwiftUI offers a second, real, framework-native form of dependency injection: a custom EnvironmentKey.

struct APIClientKey: EnvironmentKey { static let defaultValue: APIClientProtocol = APIClient() } extension EnvironmentValues { var apiClient: APIClientProtocol { get { self[APIClientKey.self] } set { self[APIClientKey.self] = newValue } } } // Reading it in a View: @Environment(\.apiClient) private var apiClient
ApproachReal Best Fit
Initializer InjectionOne ViewModel's own specific dependency — explicit, real, and directly testable per-instance
Custom @EnvironmentA real, genuinely shared service many unrelated Views need, without threading it through every intermediate initializer

Hands-On Exercises

Exercise 1

Define a real protocol QuoteFetching with one method, func fetchQuote() async throws -> Quote, make Quote's own real fetch function conform via a struct, and refactor QuoteHeaderView (from Fundamentals' capstone) to accept a QuoteFetching value via its own initializer instead of calling fetchQuote() directly.

📄 View solution
Exercise 2

Write a real FakeQuoteFetcher: QuoteFetching returning a fixed, predictable Quote instantly, with no real network call — then use it in a #Preview for QuoteHeaderView.

📄 View solution
Exercise 3

Explain, in your own words, why depending on APIClientProtocol rather than the concrete APIClient struct is specifically what makes substituting FakeAPIClient possible, and why a default parameter value on the initializer means existing, real app code needs no changes at all.

📄 View solution

Chapter 5 Quick Reference

  • A type creating its own dependencies internally is a real, genuine testing dead end
  • Depending on a real protocol, not a concrete type, is what makes substituting a fake possible
  • Initializer injection with a default parameter value keeps ordinary app code unchanged while enabling real tests/Previews to substitute a fake
  • A real fake conforming to the same protocol returns predictable data instantly — no real network involved
  • A custom EnvironmentKey/@Environment value is SwiftUI's own native DI mechanism for services shared across many unrelated Views