Codementor Events

How and why I built Mastering Dependency Injection in iOS with Swift: Streamline Your App Development and Testing

Published Apr 26, 2023
How and why I built Mastering Dependency Injection in iOS with Swift: Streamline Your App Development and Testing

About me

iOS Engineer

The problem I wanted to solve

Easing the process of swapping out dependencies for testing or when you need to change the implementation.

What is Mastering Dependency Injection in iOS with Swift: Streamline Your App Development and Testing?

Dependency injection is a design pattern that promotes the decoupling of components in your code. Instead of having a class create its dependencies directly, dependencies are "injected" into the class, typically via constructor or method parameters. This approach makes it easy to swap out dependencies for testing or when you need to change the implementation.

Benefits of Dependency Injection:
Improved modularity: Decoupling components allows for easy dependency swapping without affecting other parts of your code.

Testability: Injecting dependencies allows you to substitute mock objects or stubs during testing, making it easier to write unit tests for your code.

Maintainability: With clear separation of concerns, your code becomes more maintainable and easier to understand.

Tech stack

Swift programming language, DispatchQueue, Mac OSX latest, test device iOS latest, Xcode.

The process of building Mastering Dependency Injection in iOS with Swift: Streamline Your App Development and Testing

Implementing Dependency Injection in iOS Swift
We'll demonstrate dependency injection by creating a simple example of a data fetcher class that relies on a DispatchQueueProtocol for performing asynchronous operations.

Define the protocol:

protocol DispatchQueueProtocol {
    func async(execute workItem: DispatchWorkItem)
    func async(group: DispatchGroup, execute workItem: DispatchWorkItem)
}

2. Provide a default implementation:

extension DispatchQueueProtocol 
    func async(group: DispatchGroup? = nil, qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = [], execute work: @escaping () -> Void) {
        let item = DispatchWorkItem(qos: qos, flags: flags, block: work)
        if let group = group {
            async(group: group, execute: item)
        } else {
            async(execute: item)
        }
    }
}

3. Extend DispatchQueue to conform to DispatchQueueProtocol:

extension DispatchQueue: DispatchQueueProtocol {}

4. Create the DataFetcher Class

class DataFetcher {

  private let dispatchQueue: DispatchQueueProtocol

  init(dispatchQueue: DispatchQueueProtocol) {

    self.dispatchQueue = dispatchQueue

  }

  func fetchData(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {

    dispatchQueue.async {

      do {

        let data = try Data(contentsOf: url)

        completion(.success(data))

      } catch {

        completion(.failure(error))

      }

    }

  }

}

5. Use the DataFetcher Class in your app

let dataFetcher = DataFetcher(dispatchQueue: DispatchQueue.main)

let url = URL(string: "https://example.com/data")!

dataFetcher.fetchData(from: url) { result in

  switch result {

  case .success(let data):

    print("Fetched data: \(data)")

  case .failure(let error):

    print("Error fetching data: \(error)")

  }
}

6. Create a FakeDispatchQueueProtocol for testing:

final class FakeDispatchQueueProtocol: DispatchQueueProtocol {
    var asyncGroup: DispatchGroup?
    var asyncWorkItem: DispatchWorkItem?

    let asyncSemaphore = DispatchSemaphore(value: 1)
    private let queue = DispatchQueue(label: "fake")

    func async(execute workItem: DispatchWorkItem) {
        asyncGroup = nil
        asyncWorkItem = workItem

        queue.async { [self] in
            asyncSemaphore.wait()
            workItem.perform()
            asyncSemaphore.signal()
        }
    }
    
func async(group: DispatchGroup, execute workItem: DispatchWorkItem) {
        asyncGroup = group
        asyncWorkItem = workItem

        queue.async { [self] in
            asyncSemaphore.wait()
            workItem.perform()
            asyncSemaphore.signal()
        }
    }
}

Now you have the complete FakeDispatchQueueProtocol class. This class is a mock implementation of the DispatchQueueProtocol that can be used during unit testing. It allows you to control the execution of asynchronous operations and ensures that your tests don't depend on actual system resources.

With this mock implementation, you can write unit tests for your DataFetcher class, using the FakeDispatchQueueProtocol to manage the execution of asynchronous operations. Here's an example of how you could write a test for the fetchData method:

    let fakeDispatchQueue = FakeDispatchQueueProtocol()
    let dataFetcher = DataFetcher(dispatchQueue: fakeDispatchQueue)


    let testURL = URL(string: "https://example.com/data")!
    let expectedData = Data()


    // Replace the actual data fetching with a mocked version for testing
    URLProtocolMock.testURLs = [testURL: expectedData]
    let config = URLSessionConfiguration.ephemeral
    config.protocolClasses = [URLProtocolMock.self]
    let urlSession = URLSession(configuration: config)


    let expectation = XCTestExpectation(description: "Fetch data")


    dataFetcher.fetchData(from: testURL) { result in
        switch result {
        case .success(let data):
            XCTAssertEqual(data, expectedData, "Fetched data should match the expected data")
        case .failure:
            XCTFail("Data fetching should succeed")
        }
        expectation.fulfill()
    }


    wait(for: [expectation], timeout: 1.0)
}

By using dependency injection and a mock implementation of the DispatchQueueProtocol, you can ensure that your unit tests are reliable, independent of actual system resources, and easier to maintain.

With the DataFetcher class and the FakeDispatchQueueProtocol for testing, you now have a comprehensive understanding of dependency injection in iOS Swift. You can utilize these concepts to enhance your app's modularity, testability, and maintainability.
1.
To further extend this example, you could create additional methods within the DataFetcher class for specific data fetching tasks, or even design a more generic API client that handles various types of requests.

Key learnings

To apply dependency injection in other areas of your app, consider the following steps:

  1. Identify dependencies between your classes and components
  2. Define protocols for these dependencies to serve as abstractions.
  3. Modify your classes to accept these protocols as parameters in their initializers or methods, rather than creating the dependencies directly.
  4. Extend existing classes or create new ones that conform to these protocols, providing alternative implementations or mock objects for testing purposes.

By consistently applying dependency injection throughout your app, you will create a more flexible and maintainable codebase, which will ultimately improve the overall quality and stability of your app.

Remember that dependency injection is just one of many design patterns and best practices that can help you build better iOS apps. To continue learning and improving your skills as an iOS developer, consider exploring other design patterns, best practices, and Apple's guidelines for app development.

Final thoughts and next steps

Dependency injection is an indispensable technique for creating flexible, maintainable, and testable iOS apps. By embracing this design pattern and consistently applying it throughout your codebase, you can optimize your development process and elevate the quality of your app projects. As you continue to grow as an iOS developer, make sure to explore other design patterns and best practices to further enhance your skills and improve your app development capabilities.

Discover and read more posts from Basel Farag
get started
post commentsBe the first to share your opinion
Show more replies