Swift Mocking with Protocols: Dependency Injection and Test Doubles

Swift Mocking with Protocols: Dependency Injection and Test Doubles

Swift doesn't have a reflection-based mocking framework like Java's Mockito — the language's static type system makes runtime proxy generation impractical. Instead, Swift mocking relies on protocols and dependency injection. The approach is more explicit but also more type-safe and faster at runtime.

The Foundation: Protocol-Based Abstraction

The key insight is to depend on protocols (interfaces), not concrete types:

// Don't depend on this directly
class URLSessionHTTPClient {
    func get(url: URL) async throws -> Data { ... }
}

// Depend on this instead
protocol HTTPClient {
    func get(url: URL) async throws -> Data
}

// Production implementation
class URLSessionHTTPClient: HTTPClient {
    func get(url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

// Test implementation
class MockHTTPClient: HTTPClient {
    var stubbedData: Data = Data()
    var stubbedError: Error?

    func get(url: URL) async throws -> Data {
        if let error = stubbedError { throw error }
        return stubbedData
    }
}

Manual Mock Pattern

The standard pattern for a manual mock:

protocol UserRepository {
    func findById(_ id: Int) async throws -> User?
    func save(_ user: User) async throws
    func delete(id: Int) async throws
}

final class MockUserRepository: UserRepository {

    // Stubs
    var stubbedUser: User?
    var stubbedSaveError: Error?
    var stubbedDeleteError: Error?

    // Spy tracking
    var findByIdCallCount = 0
    var findByIdCalledWith: [Int] = []
    var savedUsers: [User] = []
    var deletedIds: [Int] = []

    func findById(_ id: Int) async throws -> User? {
        findByIdCallCount += 1
        findByIdCalledWith.append(id)
        return stubbedUser
    }

    func save(_ user: User) async throws {
        if let error = stubbedSaveError { throw error }
        savedUsers.append(user)
    }

    func delete(id: Int) async throws {
        if let error = stubbedDeleteError { throw error }
        deletedIds.append(id)
    }
}

Dependency Injection Patterns

Constructor Injection (Preferred)

final class UserService {
    private let repository: UserRepository
    private let emailService: EmailService

    init(repository: UserRepository, emailService: EmailService) {
        self.repository = repository
        self.emailService = emailService
    }

    func createUser(name: String, email: String) async throws -> User {
        let user = User(name: name, email: email)
        try await repository.save(user)
        try await emailService.sendWelcome(to: email)
        return user
    }
}

In tests:

func testCreateUser_savesAndSendsEmail() async throws {
    let mockRepo = MockUserRepository()
    let mockEmail = MockEmailService()
    let service = UserService(repository: mockRepo, emailService: mockEmail)

    _ = try await service.createUser(name: "Alice", email: "alice@example.com")

    XCTAssertEqual(mockRepo.savedUsers.count, 1)
    XCTAssertEqual(mockRepo.savedUsers.first?.name, "Alice")
    XCTAssertEqual(mockEmail.sentEmails, ["alice@example.com"])
}

Default Parameter Injection

For simpler cases, provide a default in the initializer:

final class UserService {
    private let repository: UserRepository

    init(repository: UserRepository = LiveUserRepository()) {
        self.repository = repository
    }
}

Production code uses the default. Tests pass a mock.

Property Injection

Useful when you can't change the initializer (e.g., ViewControllers from storyboards):

class LoginViewController: UIViewController {
    var authService: AuthService = LiveAuthService()  // settable for tests
}

// In tests
let vc = LoginViewController()
vc.authService = MockAuthService()

Spy Pattern

A spy records calls without changing behavior:

final class SpyUserRepository: UserRepository {
    private let wrapped: UserRepository
    var savedUsers: [User] = []

    init(wrapping repository: UserRepository) {
        self.wrapped = repository
    }

    func save(_ user: User) async throws {
        savedUsers.append(user)
        try await wrapped.save(user)  // delegate to real implementation
    }

    func findById(_ id: Int) async throws -> User? {
        try await wrapped.findById(id)
    }
}

Spies are useful for integration tests where you want real behavior but also want to verify interactions.

Protocol Extensions for Default Behavior

When a protocol has many methods but tests only care about a few, provide default no-op implementations:

protocol AnalyticsService {
    func track(event: String)
    func track(event: String, properties: [String: Any])
    func identify(userId: String)
    func reset()
}

extension AnalyticsService {
    func track(event: String) {}
    func track(event: String, properties: [String: Any]) {}
    func identify(userId: String) {}
    func reset() {}
}

// Minimal mock — only override what the test cares about
final class MockAnalyticsService: AnalyticsService {
    var trackedEvents: [String] = []

    func track(event: String) {
        trackedEvents.append(event)
    }
}

Testing ViewModels

The protocol + injection pattern shines for MVVM:

@MainActor
final class LoginViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let authService: AuthService

    init(authService: AuthService) {
        self.authService = authService
    }

    func login() async {
        isLoading = true
        errorMessage = nil
        do {
            try await authService.login(email: email, password: password)
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }
}

Test:

@MainActor
final class LoginViewModelTests: XCTestCase {

    func testLogin_success_clearsError() async {
        let mockAuth = MockAuthService()
        let vm = LoginViewModel(authService: mockAuth)
        vm.email = "user@example.com"
        vm.password = "password"

        await vm.login()

        XCTAssertNil(vm.errorMessage)
        XCTAssertFalse(vm.isLoading)
    }

    func testLogin_failure_setsErrorMessage() async {
        let mockAuth = MockAuthService()
        mockAuth.stubbedError = AuthError.invalidCredentials
        let vm = LoginViewModel(authService: mockAuth)
        vm.email = "wrong@example.com"
        vm.password = "bad"

        await vm.login()

        XCTAssertNotNil(vm.errorMessage)
    }
}

Generating Mocks with Sourcery

For large protocols, writing manual mocks is tedious. Sourcery generates them automatically:

  1. Install: brew install sourcery
  2. Annotate protocols in the source:
// sourcery: AutoMockable
protocol UserRepository {
    func findById(_ id: Int) async throws -> User?
    func save(_ user: User) async throws
}
  1. Add a Sourcery template that generates MockUserRepository with call tracking and stubs.
  2. Run sourcery in CI to keep mocks in sync with protocols.

SwiftMock

SwiftMock and similar libraries provide annotation-based mock generation for Swift:

@Mock
protocol NetworkService {
    func fetch(url: URL) async throws -> Data
}

// Generated: MockNetworkService with stub/verify support

Check each library's Swift version compatibility before adopting — the ecosystem here is more fragmented than Kotlin's MockK.

Common Pitfalls

Testing through the mock, not the system. If your test only exercises the mock, it proves nothing. The system under test must call the mock, and you assert on the effects.

Over-mocking. If a test requires mocking 5 collaborators, the unit under test is probably doing too much. Consider splitting it.

Mocks that return mocks. If your mock's method returns another mock, your design has a Law of Demeter violation. Refactor the production code.

Mutable shared state. If multiple tests share a mock instance, failures can cascade. Create fresh mocks in setUp.

Production Monitoring

Protocol-based tests verify logic with test doubles. For behavioral verification against real dependencies — live APIs, real databases, actual network conditions — HelpMeTest monitors production endpoints 24/7 without requiring source code access.

Summary

  • Depend on protocols, not concrete types — this makes code testable
  • Constructor injection is the cleanest approach: pass dependencies in the initializer
  • Manual mocks combine stubs (return values) and spy tracking (call counts, arguments)
  • Protocol extensions with default no-op implementations reduce boilerplate
  • Use Sourcery for automatic mock generation on large codebases
  • @MainActor ViewModels test cleanly with async test methods

Read more