Protocol-based approach

Protocol-Oriented Programming (POP) is a powerful paradigm in Swift that encourages designing your systems around protocols (interfaces) rather than concrete classes. This approach leads to more flexible, decoupled, and highly testable code. This guide will walk you through the benefits with concrete examples.

Instead of a class directly depending on another concrete class, it depends on a protocol. This protocol defines a “contract”—what methods and properties a conforming type must implement.

Let’s imagine we need a service to fetch user data.

Step 1: Define the Protocol

protocol UserDataFetching {
    func fetchUser(withId id: String, completion: @escaping (Result<User, Error>) -> Void)
}

struct User: Equatable {
    let id: String
    let name: String
    let email: String
}

enum DataFetchingError: Error, Equatable {
    case networkError
    case userNotFound
    case unknown
}

Here, UserDataFetching is our contract. Any service that fetches user data must conform to this protocol.

Step 2: Create a Concrete Implementation

// A concrete implementation of the UserDataFetching protocol
class APIService: UserDataFetching {
    func fetchUser(withId id: String, completion: @escaping (Result<User, Error>) -> Void) {
        // Simulate a network request
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            if id == "123" {
                let user = User(id: "123", name: "Alice Wonderland", email: "[email protected]")
                completion(.success(user))
            } else if id == "error" {
                completion(.failure(DataFetchingError.networkError))
            }
            else {
                completion(.failure(DataFetchingError.userNotFound))
            }
        }
    }
}
```APIService` is a real service that might make actual network calls.

**Step 3: Create a Class that Uses the Service (ViewModel or Manager)**

swift
// A ViewModel that depends on the UserDataFetching protocol
class UserViewModel {
    private let dataFetcher: UserDataFetching // Depends on the protocol, not APIService
    private(set) var user: User?
    private(set) var errorMessage: String?
    private(set) var isLoading: Bool = false

    init(dataFetcher: UserDataFetching) {
        self.dataFetcher = dataFetcher
    }

    func loadUser(withId id: String) {
        isLoading = true
        errorMessage = nil
        user = nil

        dataFetcher.fetchUser(withId: id) { [weak self] result in
            Dispatchqueue.main.async { // Ensure UI updates on main thread
                self?.isLoading = false
                switch result {
                case .success(let fetchedUser):
                    self?.user = fetchedUser
                case .failure(let error):
                    if let dataError = error as? DataFetchingError {
                         switch dataError {
                            case .networkError:
                                self?.errorMessage = "Network connection lost. Please try again."
                            case .userNotFound:
                                self?.errorMessage = "User not found."
                            case .unknown:
                                self?.errorMessage = "An unknown error occurred."
                         }
                    } else {
                        self?.errorMessage = "An unexpected error occurred: \(error.localizedDescription)"
                    }
                }
            }
        }
    }
}

Notice how UserViewModel takes a UserDataFetching object in its initializer. It doesn’t know or care if it’s an APIService or something else, as long as it fulfills the contract. This is Dependency Inversion.

Benefits for Unit Testing

This decoupling is where the magic happens for unit testing:

  1. Isolation: You can test UserViewModel in complete isolation without needing a real network connection or a running backend.
  2. Control: You can create “mock” implementations of UserDataFetching that return predictable data or errors, allowing you to test various scenarios easily.
  3. Speed: Mocked tests run significantly faster than tests relying on real network calls or databases.
  4. Determinism: Tests become deterministic because you control the inputs and outputs of your dependencies.

Let’s write unit tests for UserViewModel. We’ll need XCTest.

Step 1: Create a Mock Implementation of UserDataFetching

// Mock implementation for testing purposes
class MockUserDataFetcher: UserDataFetching {
    var fetchUserResult: Result<User, Error>?
    var fetchUserCalledWithId: String?
    var fetchUserCallCount = 0

    func fetchUser(withId id: String, completion: @escaping (Result<User, Error>) -> Void) {
        fetchUserCallCount += 1
        fetchUserCalledWithId = id
        if let result = fetchUserResult {
            // Simulate async behavior if needed, or call directly for simplicity in some tests
            DispatchQueue.global().async { // Simulate async behavior
                 completion(result)
            }
        } else {
            completion(.failure(DataFetchingError.unknown))
        }
    }

    // Helper to reset mock state between tests
    func reset() {
        fetchUserResult = nil
        fetchUserCalledWithId = nil
        fetchUserCallCount = 0
    }
}

This MockUserDataFetcher allows us to:

  • Specify what Result (.success or .failurefetchUser should return.
  • Verify if fetchUser was called.
  • Check how many times it was called and with what id.
import XCTest
@testable import MyApp // Replace MyApp with your actual module name

class UserViewModelTests: XCTestCase {

    var viewModel: UserViewModel!
    var mockFetcher: MockUserDataFetcher!

    override func setUp() {
        super.setUp()
        mockFetcher = MockUserDataFetcher()
        viewModel = UserViewModel(dataFetcher: mockFetcher)
    }

    override func tearDown() {
        viewModel = nil
        mockFetcher = nil
        super.tearDown()
    }

    func testLoadUser_Success_SetsUserAndClearsError() {
        let expectedUser = User(id: "123", name: "Test User", email: "[email protected]")
        mockFetcher.fetchUserResult = .success(expectedUser)
        let expectation = self.expectation(description: "Fetch user success")

        viewModel.loadUser(withId: "123")

        XCTAssertTrue(viewModel.isLoading, "isLoading should be true immediately after calling loadUser")

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // Allow async operations to complete
            XCTAssertFalse(self.viewModel.isLoading, "isLoading should be false after fetch completes")
            XCTAssertEqual(self.viewModel.user, expectedUser, "User should be set to the fetched user")
            XCTAssertNil(self.viewModel.errorMessage, "Error message should be nil on success")
            XCTAssertEqual(self.mockFetcher.fetchUserCallCount, 1, "fetchUser should be called once")
            XCTAssertEqual(self.mockFetcher.fetchUserCalledWithId, "123", "fetchUser should be called with the correct id")
            expectation.fulfill()
        }
        waitForExpectations(timeout: 1.0, handler: nil)
    }

    func testLoadUser_Failure_SetsErrorMessageAndClearsUser() {
        let expectedError = DataFetchingError.userNotFound
        mockFetcher.fetchUserResult = .failure(expectedError)
        let expectation = self.expectation(description: "Fetch user failure")

        viewModel.loadUser(withId: "unknown")

        XCTAssertTrue(viewModel.isLoading)

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            XCTAssertFalse(self.viewModel.isLoading)
            XCTAssertNil(self.viewModel.user, "User should be nil on failure")
            XCTAssertEqual(self.viewModel.errorMessage, "User not found.", "Error message should be set correctly")
            XCTAssertEqual(self.mockFetcher.fetchUserCallCount, 1)
            XCTAssertEqual(self.mockFetcher.fetchUserCalledWithId, "unknown")
            expectation.fulfill()
        }
        waitForExpectations(timeout: 1.0, handler: nil)
    }

    func testLoadUser_NetworkError_SetsAppropriateErrorMessage() {
        mockFetcher.fetchUserResult = .failure(DataFetchingError.networkError)
        let expectation = self.expectation(description: "Fetch user network error")
        viewModel.loadUser(withId: "error")

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            XCTAssertFalse(self.viewModel.isLoading)
            XCTAssertEqual(self.viewModel.errorMessage, "Network connection lost. Please try again.")
            expectation.fulfill()
        }
        waitForExpectations(timeout: 1.0, handler: nil)
    }
    
    func testLoadUser_SetsIsLoadingCorrectly() {
        let expectation = self.expectation(description: "isLoading state changes correctly")
        mockFetcher.fetchUserResult = .success(User(id: "temp", name: "Temp", email: "[email protected]"))

        XCTAssertFalse(viewModel.isLoading, "isLoading should be false initially")
        viewModel.loadUser(withId: "temp")
        XCTAssertTrue(viewModel.isLoading, "isLoading should be true immediately after calling loadUser")

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            XCTAssertFalse(self.viewModel.isLoading, "isLoading should be false after fetch completes")
            expectation.fulfill()
        }
        waitForExpectations(timeout: 1.0, handler: nil)
    }
}
  • setUp(): This method is called before each test. We initialize our mockFetcher and viewModel, injecting the mock into the view model.
  • tearDown(): Called after each test to clean up.
  • testLoadUser_Success...: We configure the mockFetcher to return a successful result. Then we call viewModel.loadUser(). We use an XCTestExpectation to handle the asynchronous nature of the data fetching. Finally, we assert that the viewModel‘s state (usererrorMessageisLoading) is updated correctly and that the mock was called as expected.
  • testLoadUser_Failure...: Similar to the success test, but we configure the mock to return a failure and check if the error message is set.
  • DispatchQueue.main.asyncAfter: This is used to give the asynchronous completion block in loadUser (and the mock’s simulated async call) a chance to execute and update the viewModel‘s properties on the main thread before we make our assertions.
  • @testable import MyApp: This line imports your app’s module in a way that allows tests to access internalmembers. Replace MyApp with the actual name of your app module.
  • Someone might argue this is not a great way of testing an async method, and I completely agree. Since it will make your test case flaky. How to solve that issue?
swift
// Create a protocol

public protocol DispatchQueueType {
  func async(execute work: @escaping @Sendable @convention(block) () -> Void)
}

extension DispatchQueue: DispatchQueueType {
  public func async(
         execute work: @escaping @Sendable @convention(block) () -> Void
        ) {
           async(qos: .unspecified, flags: [], execute: work)
        }
}

// Now all we need is to inject this dependency

So lets update our view model
...
...
let mainDispatchQueue: DispatchQueueType
...

init(dataFetcher: UserDataFetching,      mainDispatchQueue: DispatchQueueType = DispatchQueue.main) {        self.dataFetcher = dataFetcher        self.mainDispatchQueue = mainDispatchQueue
    }

....
then change this func 

dataFetcher.fetchUser(withId: id) { [weak self] result in
            self.mainDispatchQueue.async { // Ensure UI updates on main thread
.....

// Create the Mock 
class MockDispatchQueue: DispatchQueueType {
    func async(execute work: @escaping @convention(block) () -> Void) {
      work()
    }
  }

... inject in the test and our test will be simple as 

func testLoadUser_Success_SetsUserAndClearsError() {
        let expectedUser = User(id: "123", name: "Test User", email: "[email protected]")
        mockFetcher.fetchUserResult = .success(expectedUser)

        XCTAssertFalse(viewModel.isLoading, "isLoading should be false initially")
        viewModel.loadUser(withId: "123")

        // Assert final state after main queue dispatch
        XCTAssertFalse(viewModel.isLoading, "isLoading should be false after fetch completes and main queue updates")
        XCTAssertEqual(viewModel.user, expectedUser, "User should be set to the fetched user")
        XCTAssertNil(viewModel.errorMessage, "Error message should be nil on success")
    }
 
  • Flexibility & Swappability: You can easily swap out implementations. For example, you could create a CoreDataUserService or a MockedSuccessUserService for UI previews without changing UserViewModel.
  • Clearer APIs: Protocols define clear boundaries and responsibilities between components.
  • Reduced Coupling: Components are less dependent on concrete implementations of other components, making the system easier to maintain and evolve.
  • Parallel Development: Different teams can work on different components concurrently once the protocol contracts are defined.

Adopting a protocol-based approach in Swift is a cornerstone of writing clean, maintainable, and, most importantly, highly testable code. By defining contracts through protocols and using dependency injection, you can isolate components for unit testing, create flexible mock objects, and build more robust and reliable applications. This technique empowers developers to verify the logic of their components thoroughly and with greater confidence.

Leave a Reply

Your email address will not be published. Required fields are marked *