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:
- Install:
brew install sourcery - Annotate protocols in the source:
// sourcery: AutoMockable
protocol UserRepository {
func findById(_ id: Int) async throws -> User?
func save(_ user: User) async throws
}- Add a Sourcery template that generates
MockUserRepositorywith call tracking and stubs. - Run
sourceryin 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 supportCheck 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
@MainActorViewModels test cleanly withasynctest methods