iosNudge

How do you mock dependencies in Swift tests? What is the difference between a stub, a mock, a fake and a spy?

MediumAsked oftenTestingArchitecture 2 min read

TL;DR

Swift has no runtime mocking like Mockito. You mock by design:

  1. Put the dependency behind a protocol or a struct of closures.
  2. Inject it through init.
  3. In tests, pass a test double.

Kinds of test doubles: stub (returns fixed data), spy (records calls), mock (checks expected calls), fake (a simple working version).

Step 1: a protocol

protocol WeatherService: Sendable {
    func temperature(in city: String) async throws -> Double
}

@MainActor @Observable
final class WeatherModel {
    private(set) var text = ""
    private let service: WeatherService

    init(service: WeatherService) { self.service = service }

    func load(city: String) async {
        do {
            let t = try await service.temperature(in: city)
            text = "\(Int(t))°"
        } catch {
            text = "Unavailable"
        }
    }
}

Step 2: test doubles

Stub: returns what you tell it

struct StubWeatherService: WeatherService {
    var result: Result<Double, Error>
    func temperature(in city: String) async throws -> Double {
        try result.get()
    }
}

Spy: records what happened

actor SpyWeatherService: WeatherService {
    private(set) var requestedCities: [String] = []
    func temperature(in city: String) async throws -> Double {
        requestedCities.append(city)
        return 20
    }
}

Fake: a lightweight real implementation

final class InMemoryNotesStore: NotesStore {
    private var notes: [Note] = []
    func save(_ note: Note) { notes.append(note) }
    func all() -> [Note] { notes }
}

Mock: knows what calls to expect and verifies them

A mock is a spy plus built-in checks, often from a library. In Swift, most teams just use a spy and assert in the test.

Step 3: tests

import Testing

@MainActor
struct WeatherModelTests {
    @Test func showsTemperature() async {
        let model = WeatherModel(service: StubWeatherService(result: .success(21.6)))
        await model.load(city: "Oslo")
        #expect(model.text == "21°")
    }

    @Test func showsErrorText() async {
        let model = WeatherModel(service: StubWeatherService(result: .failure(URLError(.timedOut))))
        await model.load(city: "Oslo")
        #expect(model.text == "Unavailable")
    }

    @Test func asksForTheRightCity() async {
        let spy = SpyWeatherService()
        let model = WeatherModel(service: spy)
        await model.load(city: "Lima")
        #expect(await spy.requestedCities == ["Lima"])
    }
}

Closures instead of protocols

Less code. Each test overrides only what it needs.

struct WeatherClient: Sendable {
    var temperature: @Sendable (String) async throws -> Double
}

let client = WeatherClient(temperature: { _ in 30 })

Mocking the network layer

To test your real API client without a server, use a custom URLProtocol:

final class MockURLProtocol: URLProtocol {
    nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        guard let (response, data) = Self.handler?(request) else { return }
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    }
    override func stopLoading() {}
}

let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)

This tests URL building, headers and decoding together. Because the handler is shared, run these tests serialized.

Things to inject besides services

  • Time: a Clock or a now: () -> Date closure. Never real sleep in tests.
  • UUIDs and random values.
  • UserDefaults: use UserDefaults(suiteName:) with a unique name per test.
  • File locations: a temporary directory.

Common mistakes

  • Mocking value types or pure functions. Just call them.
  • Testing the mock instead of the real code.
  • Too many protocols "just in case". Add one when you need a second implementation.
  • Over-checking calls (exact order, exact count) when only the result matters. Tests become fragile.

Common follow-up questions

  • Are there mocking libraries? Yes, for example Mockolo or Cuckoo, which generate mocks from protocols. Many teams write doubles by hand.
  • How do you mock a singleton? Hide it behind a protocol and inject .shared as the default argument.
  • How do you mock Date()? Inject a closure or a Clock and pass a fixed value in tests.
  • Why make the spy an actor? It is called from async code. An actor keeps its recorded data safe in Swift 6.