Skip to content
intermediate Phase 10 · Testing & Debugging

Mocking & Test Doubles

Create mocks, stubs, fakes, and spies for isolating units under test.

45m
2 problems
Topic Progress 0%

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?

Question 1 options

2. Which dependency injection style is most testable?

Question 2 options

3. What is a spy in test double terminology?

Question 3 options

4. Why use protocols instead of concrete types for dependencies?

Question 4 options

Flashcards

Question

What are the four types of test doubles?

Answer

Stub (predetermined responses), Mock (records and verifies calls), Fake (simplified working implementation), Spy (records interactions for assertion).

Question

What is constructor injection?

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?

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