Every class needs things to do its job. An oven ViewModel needs a network client to call the API. A fridge ViewModel needs to know when a door has been open too long, which means it needs a way to send notifications. A login screen needs something to authenticate against.
The naive way to get those things is to reach out and grab them directly:
class OvenViewModel {
func toggleOven(deviceId: String) async {
// Hard-wired — goes straight to the real server
let result = try await APIService.shared.setOvenPower(...)
}
}
This works fine in the running app. The problem shows up the moment you try to write a test, or the moment a teammate needs to work on the UI before the server is ready.
If OvenViewModel always calls APIService.shared, you can’t test it without a live server. You can’t write a test that simulates a 401 Unauthorized response. You can’t verify error handling without actually breaking the server. The class has reached outside itself and grabbed a concrete dependency — it owns that decision and nothing can change it from the outside.
This is called tight coupling, and it’s the enemy of testable code.
Dependency Injection is the practice of passing a class’s dependencies in from the outside rather than letting it create or reach for them itself.
Instead of OvenViewModel knowing about APIService, it knows about a protocol — a description of what any API service must be able to do. The concrete implementation is handed in at creation time:
// The protocol describes the contract — not an implementation
protocol APIServiceProtocol: AnyObject {
func setOvenPower(deviceId: String, compartment: String?, isOn: Bool) async throws -> OvenState
// ... other methods
}
// The ViewModel only knows about the protocol
class OvenViewModel {
private let api: APIServiceProtocol
init(api: APIServiceProtocol) {
self.api = api
}
}
Now the ViewModel has no idea whether it’s talking to a real server or a fake one. In production you pass in APIService.shared. In a test you pass in a MockAPIService that returns whatever you tell it to.
This is called the Dependency Inversion Principle — depend on abstractions, not on concrete implementations. It’s the D in SOLID.
The payoff is immediate:
- Tests run without a server. Your mock returns success, failure, or an auth error on demand.
- UI development is unblocked. A designer can work with a mock that returns fixture data while the real API is still being built.
- Behaviour is explicit. When you read an
init, you can see exactly what a class needs to do its job.
This app has three injectable dependencies, each backed by a protocol and a test double.
APIServiceProtocol covers all 16 REST endpoints. Both APIService (real, hits the Node server) and MockAPIService (test double) conform to it:
protocol APIServiceProtocol: AnyObject {
var authToken: String? { get set }
func getDevices() async throws -> [DeviceInfo]
func setOvenPower(deviceId: String, compartment: String?, isOn: Bool) async throws -> OvenState
func startDishwasher(deviceId: String, cycle: String) async throws -> DishwasherState
// ... 13 more
}
MockAPIService stores stubbable Result properties and records every call:
final class MockAPIService: APIServiceProtocol {
var setOvenPowerResult: Result<OvenState, Error> = .success(.defaultOvenState)
var setOvenPowerCalls: [(deviceId: String, compartment: String?, isOn: Bool)] = []
func setOvenPower(deviceId: String, compartment: String?, isOn: Bool) async throws -> OvenState {
setOvenPowerCalls.append((deviceId, compartment, isOn))
return try setOvenPowerResult.get()
}
}
WebSocketProviding lets you inject a fake WebSocket that you can fire events on manually in tests:
protocol WebSocketProviding: AnyObject {
var onDeviceUpdate: ((String, String, String?, Data) -> Void)? { get set }
func connect(token: String)
func subscribe(deviceIds: [String])
}
TelemetryService lets tests assert that the right events were recorded without any actual logging:
let telemetry = MockTelemetryService()
let vm = OvenViewModel(api: mock, telemetry: telemetry)
await vm.toggleOven(deviceId: "oven1")
XCTAssertTrue(telemetry.contains(.ovenPowerChanged(deviceId: "oven1", compartment: nil, isOn: true)))
The production app never has to change any call sites. All ViewModels use default parameter values so the real dependencies are used automatically:
init(
api: APIServiceProtocol = APIService.shared,
webSocket: WebSocketProviding = WebSocketService.shared,
telemetry: TelemetryService = OSLogTelemetryService.shared
) { ... }
In tests you provide mocks. In production you get the defaults. No configuration required, no separate factory, no DI container. Simple, explicit, and easy to follow.
On to lesson 3 — Combine… coming soon


Leave a Reply