Protocols for Mocking
Why Protocols Matter for Testing
Swift protocols are the foundation of testable code. By depending on protocols rather than concrete implementations, you can swap real dependencies with test doubles during testing. This principle is called Dependency Inversion.
Defining Testable Protocols
// Protocol for data fetching
protocol UserDataSource {
func fetchUsers() async throws -> [User]
func saveUser(_ user: User) async throws
func deleteUser(id: UUID) async throws
}
// Protocol for networking
protocol NetworkService {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}
// Protocol for storage
protocol UserDefaultsService {
func set(_ value: Any?, forKey key: String)
func object(forKey key: String) -> Any?
}
Protocol-Oriented Architecture
Structure your code to depend on protocols at every layer:
// Repository depends on protocols, not concrete types
class UserRepository: UserDataSource {
private let network: NetworkService
private let storage: UserDefaultsService
init(network: NetworkService, storage: UserDefaultsService) {
self.network = network
self.storage = storage
}
func fetchUsers() async throws -> [User] {
if let cached = storage.object(forKey: "users") as? [User] {
return cached
}
let users: [User] = try await network.request(.users)
storage.set(users, forKey: "users")
return users
}
}
Protocol Witnesses vs Mocks
In Swift, you can use protocol witnesses (manual conformance) for lightweight testing without full mock objects:
// Simple closure-based test double
func makeUserDataSource(
fetchHandler: @escaping () async throws -> [User] = { [] }
) -> UserDataSource {
var dataSource = StubUserDataSource()
dataSource.fetchHandler = fetchHandler
return dataSource
}
// Usage in tests
let dataSource = makeUserDataSource {
[User(id: UUID(), name: "Test User")]
}
Creating Test Doubles
Types of Test Doubles
- Stub: Returns predetermined responses, no verification
- Mock: Records calls and verifies expectations
- Fake: Working implementation with simplified behavior
- Spy: Records interactions for later assertion
Creating a Mock
class MockNetworkService: NetworkService {
var requestCalls: [Endpoint] = []
var stubbedResponses: [Endpoint: Any] = [:]
var errorToThrow: Error?
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
requestCalls.append(endpoint)
if let error = errorToThrow {
throw error
}
guard let response = stubbedResponses[endpoint] as? T else {
fatalError("No stubbed response for \(endpoint)")
}
return response
}
func stub<T>(_ endpoint: Endpoint, response: T) {
stubbedResponses[endpoint] = response
}
}
Creating a Fake
A fake provides a working but simplified implementation:
class FakeUserRepository: UserDataSource {
private var users: [User] = []
func fetchUsers() async throws -> [User] {
return users
}
func saveUser(_ user: User) async throws {
users.append(user)
}
func deleteUser(id: UUID) async throws {
users.removeAll { $0.id == id }
}
// Test helper
func populate(with testData: [User]) {
users = testData
}
}
Creating a Spy
class AnalyticsSpy: AnalyticsService {
var trackedEvents: [(name: String, properties: [String: Any])] = []
var screenViews: [String] = []
func track(event: String, properties: [String: Any]) {
trackedEvents.append((name: event, properties: properties))
}
func trackScreen(_ name: String) {
screenViews.append(name)
}
func verify(event: String, occurred: Bool = true, file: StaticString = #file, line: UInt = #line) {
let found = trackedEvents.contains { $0.name == event }
if found != occurred {
XCTFail("Event \(event) \(occurred ? "not" : "") found", file: file, line: line)
}
}
}
Dependency Injection for Testing
Constructor Injection
The most straightforward approach - pass dependencies through init:
class UserProfileViewModel {
private let repository: UserRepository
private let analytics: AnalyticsService
init(repository: UserRepository, analytics: AnalyticsService) {
self.repository = repository
self.analytics = analytics
}
}
// Production
let viewModel = UserProfileViewModel(
repository: RealUserRepository(),
analytics: RealAnalytics()
)
// Testing
let viewModel = UserProfileViewModel(
repository: FakeUserRepository(),
analytics: AnalyticsSpy()
)
Environment-based Injection
Use environment objects in SwiftUI for dependency injection:
struct DependencyEnvironment {
static let production = DependencyEnvironment(
network: RealNetworkService(),
storage: RealStorageService()
)
static let testing = DependencyEnvironment(
network: MockNetworkService(),
storage: InMemoryStorage()
)
let network: NetworkService
let storage: StorageService
}
// In SwiftUI
@main
struct MyApp: App {
let env: DependencyEnvironment
init() {
#if DEBUG
self.env = ProcessInfo.processInfo.environment["TESTING"] != nil
? .testing : .production
#else
self.env = .production
#endif
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(env)
}
}
}
Protocol-Based Configuration
Use a DI container pattern:
protocol DependencyContainer {
var network: NetworkService { get }
var storage: StorageService { get }
var analytics: AnalyticsService { get }
}
struct ProductionContainer: DependencyContainer {
let network = RealNetworkService()
let storage = RealStorageService()
let analytics = RealAnalytics()
}
struct TestContainer: DependencyContainer {
let network = MockNetworkService()
let storage = InMemoryStorage()
let analytics = AnalyticsSpy()
}
This approach makes it easy to swap the entire dependency graph for tests while keeping production code clean and well-structured.
Quiz
1. What is the difference between a mock and a fake?
2. Which dependency injection style is most testable?
3. What is a spy in test double terminology?
4. Why use protocols instead of concrete types for dependencies?
Flashcards
Question
What are the four types of test doubles?
Click to reveal answer
Answer
Stub (predetermined responses), Mock (records and verifies calls), Fake (simplified working implementation), Spy (records interactions for assertion).
Question
What is constructor injection?
Click to reveal answer
Answer
Passing dependencies through a class initializer, making dependencies explicit and allowing different implementations to be injected for testing.
Question
Why is Dependency Inversion important for testing?
Click to reveal answer
Answer
It allows high-level modules to depend on abstractions (protocols) rather than concrete implementations, enabling test doubles to be substituted.
Revision Notes
Key Takeaways
- 1. Define protocols for every external dependency
- 2. Use constructor injection to make dependencies explicit
- 3. Fakes are preferred over mocks for complex dependencies
- 4. Spies let you verify behavior after execution
- 5. Avoid singletons in testable code
Interview Tips
- • Explain the difference between mocks, stubs, and fakes
- • Describe how you would test a ViewModel that depends on a network service
- • Discuss why global state makes testing difficult
- • Walk through setting up dependency injection in a SwiftUI app
Cheat Sheet
Mocking Quick Reference
- Stub: returns preset data
- Mock: records calls, verifies expectations
- Fake: working simplified implementation
- Spy: records calls for later assertion
- Use protocols for all dependencies
- Constructor injection is the simplest DI pattern