Unit tests are only useful if they’re fast and reliable. A test that takes 10 seconds because it’s hitting a real server isn’t something you run on every code change. A test that fails randomly because the server was temporarily unreachable isn’t something you trust. A test that only passes when your laptop is on the right WiFi network isn’t something that works in CI.
Most of the interesting behaviour in this app lives in the ViewModels — what happens when the API succeeds, what happens when it fails, what happens when a 401 comes back, what gets logged to telemetry. All of that is logic that should be testable, fast, and deterministic. But by default it all depends on real external systems: a live server, a real WebSocket connection, the actual os.Logger.
The solution is test doubles — objects that implement the same protocols as the real dependencies but behave in controlled, predictable ways during tests.
A mock (a specific kind of test double) does two things:
- Controls the behaviour of a dependency — you tell it what to return, so you can test the success path, the failure path, the auth-error path, all deterministically.
- Records what was called — so you can assert not just on the resulting state, but on whether the right calls were made, with the right arguments.
The design that makes mocks easy is the same design that makes everything else testable: protocol-based dependency injection. If a class depends on a protocol instead of a concrete type, you can swap the real thing for a mock at test time without changing any production code.
The cost of writing mocks is low. The benefit is that every important behaviour in your app becomes a test that runs in milliseconds, catches regressions immediately, and never depends on a server being up.
This app has three mock test doubles, each matching a production service.
MockAPIService
Controls what each API method returns and records every call made:
final class MockAPIService: APIServiceProtocol {
var authToken: String? = "test-token"
// Stubbable result for each method
var setOvenPowerResult: Result<OvenState, Error> = .success(.defaultOvenState)
var setFridgeTemperatureResult: Result<FridgeState, Error> = .success(.defaultFridgeState)
var loginResult: Result<AuthResponse, Error> = .success(.defaultAuthResponse)
// Call recorder
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()
}
}
MockWebSocketService
Lets you fire simulated events in tests — device updates, alerts, disconnects:
final class MockWebSocketService: WebSocketProviding {
var onDeviceUpdate: ((String, String, String?, Data) -> Void)?
var onAlert: ((String, String, String) -> Void)?
var onConnectionChange: ((Bool) -> Void)?
var connectCalls: [String] = []
func connect(token: String) { connectCalls.append(token) }
func disconnect() { onConnectionChange?(false) }
func simulateDeviceUpdate(deviceId: String, deviceType: String, compartment: String? = nil, data: Data) {
onDeviceUpdate?(deviceId, deviceType, compartment, data)
}
}
MockTelemetryService
Records all logged events as an array you can assert against:
final class MockTelemetryService: TelemetryService {
private(set) var events: [LogEvent] = []
func record(_ event: LogEvent) { events.append(event) }
func contains(_ event: LogEvent) -> Bool { events.contains(event) }
func reset() { events.removeAll() }
}
Putting it together — a complete test scenario
Test the happy path for toggling an oven on:
func testToggleOvenSuccess() async {
let api = MockAPIService()
api.setOvenPowerResult = .success(OvenState(isOn: true, targetTemperature: 350,
currentTemperature: 72, mode: "bake",
lastUpdated: ""))
let telemetry = MockTelemetryService()
let vm = await OvenViewModel(api: api, telemetry: telemetry)
await vm.toggleOven(deviceId: "oven1")
XCTAssertEqual(vm.ovenStates["oven1"]?.isOn, true)
XCTAssertNil(vm.error)
XCTAssertTrue(telemetry.contains(.ovenPowerChanged(deviceId: "oven1", compartment: nil, isOn: true)))
XCTAssertEqual(api.setOvenPowerCalls.count, 1)
XCTAssertEqual(api.setOvenPowerCalls[0].deviceId, "oven1")
}
Test the failure path — verify errors are set and logged:
func testToggleOvenFailure() async {
let api = MockAPIService()
api.setOvenPowerResult = .failure(URLError(.notConnectedToInternet))
let telemetry = MockTelemetryService()
let vm = await OvenViewModel(api: api, telemetry: telemetry)
await vm.toggleOven(deviceId: "oven1")
XCTAssertEqual(vm.error, .offline)
XCTAssertEqual(telemetry.commandFailures.count, 1)
XCTAssertEqual(telemetry.commandFailures[0].command, "toggleOven")
}
Test a WebSocket update reaching the ViewModel:
func testApplyWebSocketUpdate() async {
let stateData = try! JSONEncoder().encode(OvenState(isOn: true, targetTemperature: 400,
currentTemperature: 300, mode: "convect",
lastUpdated: ""))
let vm = await OvenViewModel(api: MockAPIService(), telemetry: MockTelemetryService())
await vm.applyUpdate(deviceId: "oven1", compartment: nil, data: stateData)
XCTAssertEqual(vm.ovenStates["oven1"]?.currentTemperature, 300)
XCTAssertEqual(vm.ovenStates["oven1"]?.mode, "convect")
}
Test the auth error path triggers the coordinator’s requiresReauth flag:
func testUnauthorizedErrorSetsRequiresReauth() async {
let api = MockAPIService()
api.getDevicesResult = .failure(APIError.unauthorized)
let vm = await DeviceViewModel(api: api, webSocket: MockWebSocketService(),
telemetry: MockTelemetryService())
await vm.loadDevices()
XCTAssertTrue(vm.requiresReauth)
}
Each of these tests runs in well under a second and will catch any regression in error handling, state management, or telemetry immediately — without a server, without a simulator, and without ever touching the real network.


Leave a Reply
You must be logged in to post a comment.