8.4 — Mocking & Dependency Injection

Opening scenario

Your UserProfileViewModel calls URLSession.shared directly to fetch a profile, reads UserDefaults.standard to get a feature flag, and calls Date() for the cache timestamp. You want to test it. You can’t — it talks to three singletons. The test would hit the network, depend on whatever was in UserDefaults from the last run, and fail at midnight when the date rolls over.

The fix isn’t mocking magic. It’s making those dependencies parameters.

Context taxonomy

ConceptContextWhy it mattersCommon confusion
Dependency Injection (DI)Pass collaborators in via initLets tests substitute fakes“I use DI” but everything is a singleton (X.shared inside types)
ProtocolAbstract dependency interfaceMockable seamOne protocol per type — over-engineered
Manual mockHand-written test doubleTotal control, zero depsTedious for large protocols
Mock libraryMockable, Cuckoo, MockingbirdGenerates mocks via macrosMacro tooling can be brittle across Xcode versions
@testable importAccess internal symbols from testsTest internals without making them publicConfused with import (only public symbols)
URLProtocol mockIntercept URLSession callsWorks with the real URLSessionReinventing URLSession from scratch

Concept → Why → How → Code

Concept: dependency injection means a type accepts its dependencies through its initializer (or function parameters), instead of constructing or fetching them itself. Combined with protocols, this lets tests inject fakes that record calls and return canned data.

Why: without DI, every test becomes an integration test against whatever globals happen to exist. With DI, tests are fast, deterministic, and exercise exactly the unit under test.

How: define a protocol for each external boundary (network, persistence, clock, analytics). Inject the protocol into your type. Provide a real implementation for production and a fake for tests.

Code — before and after:

Untestable version

@MainActor
final class UserProfileViewModel {
    var profile: Profile?
    var error: String?

    func load(id: String) async {
        do {
            let url = URL(string: "https://api.example.com/users/\(id)")!
            let (data, _) = try await URLSession.shared.data(from: url)  // 👎 singleton
            self.profile = try JSONDecoder().decode(Profile.self, from: data)
        } catch {
            self.error = error.localizedDescription
        }
    }
}

Injectable version

protocol UserAPI {
    func loadUser(id: String) async throws -> Profile
}

@MainActor
final class UserProfileViewModel {
    private let api: UserAPI
    var profile: Profile?
    var error: String?

    init(api: UserAPI) { self.api = api }

    func load(id: String) async {
        do { profile = try await api.loadUser(id: id) }
        catch { self.error = error.localizedDescription }
    }
}

Manual mock

final class MockUserAPI: UserAPI {
    var stubResult: Result<Profile, Error> = .failure(URLError(.notConnectedToInternet))
    var receivedIDs: [String] = []

    func loadUser(id: String) async throws -> Profile {
        receivedIDs.append(id)
        return try stubResult.get()
    }
}

Tests

@MainActor
final class UserProfileViewModelTests: XCTestCase {
    func test_load_success_setsProfile() async {
        let mock = MockUserAPI()
        mock.stubResult = .success(Profile(id: "1", name: "Ada"))
        let sut = UserProfileViewModel(api: mock)

        await sut.load(id: "1")

        XCTAssertEqual(sut.profile?.name, "Ada")
        XCTAssertEqual(mock.receivedIDs, ["1"])
    }

    func test_load_failure_setsErrorMessage() async {
        let mock = MockUserAPI()
        mock.stubResult = .failure(URLError(.timedOut))
        let sut = UserProfileViewModel(api: mock)

        await sut.load(id: "1")

        XCTAssertNotNil(sut.error)
        XCTAssertNil(sut.profile)
    }
}

URLProtocol — the most underused mock

When you want to test the actual URLSession integration (header construction, query encoding) without a real network:

final class MockURLProtocol: URLProtocol {
    static var stub: (Data, HTTPURLResponse)?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
    override func startLoading() {
        guard let (data, response) = Self.stub else { return }
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    }
    override func stopLoading() {}
}

// In test:
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
MockURLProtocol.stub = (jsonData, HTTPURLResponse(...))

This is how every serious iOS project mocks the network — no third-party libraries needed.

Clocks and dates

Hard-coded Date() calls are non-deterministic. Inject a clock:

protocol Clock { func now() -> Date }
struct SystemClock: Clock { func now() -> Date { Date() } }
struct FakeClock: Clock {
    var fixed: Date
    func now() -> Date { fixed }
}

In Swift 5.7+ you can also use the standard library’s Clock protocol for time-based work (ContinuousClock, SuspendingClock).

@testable import

@testable import MyApp

Exposes internal symbols (the default access level) to the test target. Don’t make symbols public just for tests — that’s leaking abstraction.

In the wild

  • Mockable (Sahin) — Swift macro-based mock generator; clean and modern but tied to macro tooling stability.
  • Cuckoo — long-standing manual mock generator; mature but verbose.
  • Mockingbird — once-popular, less active now.
  • swift-dependencies (Point-Free) — a structured way to register and inject dependencies app-wide; pairs with TCA but works standalone.
  • Apple’s swift-async-algorithms — provides AsyncStream for testing time-based code.

Common misconceptions

  1. “DI requires a container framework.” No. Constructor injection (passing dependencies as init parameters) is DI. You don’t need Resolver, Swinject, or Needle.
  2. “Protocols make code slower.” Negligible. Swift can devirtualize protocol calls when the conforming type is known.
  3. “Mocks should record every call.” They should record what you assert on. Over-recording leaks implementation detail into tests.
  4. @testable import is a security risk.” It’s a compile-time directive only; production code can’t @testable import anything.
  5. “You must mock every dependency.” Mock only at boundaries — network, disk, clock, analytics, hardware. Don’t mock pure value types; instantiate the real thing.

Seasoned engineer’s take

A type with five injected dependencies is screaming at you that it has too many responsibilities. The pain of building a mock for it in tests is the same pain you’d feel reading it in production six months from now. Refactor the type, don’t add yet another protocol. Constructor injection plus protocols is 95% of what teams need; reach for a DI container only when you’re wiring an app with 50+ services.

[!TIP] Default test mocks to failing behavior (throw, return invalid data, crash). Each test then opts into success by setting the stub. This catches missing setup quickly — you can’t accidentally pass with a default value that happens to be benign.

[!WARNING] URLSession.shared is a singleton with cookies, caches, and an HTTP/2 connection pool that persists between tests. Always create a fresh URLSession(configuration:) per test, or your tests will see ghost data.

Interview corner

Junior — “Why use protocols for testing?” A protocol lets a type depend on an interface instead of a concrete class. In tests, you swap in a mock that conforms to the same protocol. In production, you use the real implementation. The type doesn’t know or care which is which.

Mid — “Walk me through making URLSession mockable without a third-party library.” Define a protocol HTTPClient with the methods your code uses (func data(for: URLRequest) async throws -> (Data, URLResponse)). Make URLSession conform via an extension. Inject HTTPClient into your service. For tests, either implement a manual mock or use URLProtocol-based interception, which gives you a real URLSession configured with a custom protocolClasses that returns canned responses.

Senior — “How do you decide what to mock and what to use the real implementation for?” Three rules. One: mock things that cross trust boundaries — network, disk, clock, hardware, analytics. Two: don’t mock pure value types; the real Profile struct is faster than MockProfile. Three: don’t mock the type under test or anything reachable only from it — that’s testing the mock, not the code. For complex object graphs, prefer “stub” (canned return values) over “mock” (verification of calls), because verification couples the test to implementation. I’d also consider Andrew Trick’s distinction between fakes (working in-memory implementations like InMemoryDatabase) and mocks — fakes scale better as the suite grows.

Red flag — “I make everything public so tests can see it.” This person doesn’t know about @testable import.

Lab preview

Lab 8.1 puts mocking front and center: you’ll define a Networking protocol, write a MockNetworking, and inject it into a NetworkClient under test.


Next: UI Testing with XCUITest