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.
Core Idea: Define Contracts with Protocols
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.
Example 1: A Simple Data Fetching Service
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:
- Isolation: You can test
UserViewModel
in complete isolation without needing a real network connection or a running backend. - Control: You can create “mock” implementations of
UserDataFetching
that return predictable data or errors, allowing you to test various scenarios easily. - Speed: Mocked tests run significantly faster than tests relying on real network calls or databases.
- Determinism: Tests become deterministic because you control the inputs and outputs of your dependencies.
Example 2: Unit Testing UserViewModel
with a Mock
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.failure
)fetchUser
should return. - Verify if
fetchUser
was called. - Check how many times it was called and with what
id
.
Step 2: Write the Unit Tests
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)
}
}
Explanation of the Tests:
setUp()
: This method is called before each test. We initialize ourmockFetcher
andviewModel
, injecting the mock into the view model.tearDown()
: Called after each test to clean up.testLoadUser_Success...
: We configure themockFetcher
to return a successful result. Then we callviewModel.loadUser()
. We use anXCTestExpectation
to handle the asynchronous nature of the data fetching. Finally, we assert that theviewModel
‘s state (user
,errorMessage
,isLoading
) 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 asynchronouscompletion
block inloadUser
(and the mock’s simulated async call) a chance to execute and update theviewModel
‘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 accessinternal
members. ReplaceMyApp
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")
}
Further Benefits of Protocol-Based Design:
- Flexibility & Swappability: You can easily swap out implementations. For example, you could create a
CoreDataUserService
or aMockedSuccessUserService
for UI previews without changingUserViewModel
. - 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.
Conclusion
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