SwiftUI Deep Dive: Dependency Injection Strategies and Testing Techniques

Sarim Khan
5 min readDec 2, 2023

--

Introduction:

Dependency injection (DI) is a software design pattern that provides a way to separate the creation and management of objects from the objects themselves. This makes it easier to test and maintain code, as well as to make changes to the dependencies of an object without affecting the object itself. In simpler terms, it is a technique where the dependencies of a class (i.e., the objects it relies on) are provided from the outside rather than being created within the class itself. This helps in making classes more modular, flexible, and easier to test.

There are three common types of Dependency Injection:

Constructor Injection: Dependencies are injected through the class constructor.

class NameViewModel:ObservableObject{

let serviceManager:ServiceManager

init(serviceManager: ServiceManager) {
self.serviceManager = serviceManager
}
}

Property Injection: Dependencies are injected through properties or setter methods.

class NameViewModel:ObservableObject{

var serviceManager:ServiceManager?

func initDependency(manager:ServiceManager) {
self.serviceManager = manager
}
}

Method Injection: Dependencies are injected through methods.

class NameViewModel:ObservableObject{

func initDependency(manager:ServiceManager) {}

}

Applications:

  • Loose coupling: DI makes it easier to write loosely coupled code, which means that objects are not tightly bound to their dependencies. This makes it easier to change the dependencies of an object without affecting the object itself.
  • Testability: DI makes it easier to test objects in isolation, as you can inject mock dependencies into the object instead of real dependencies. This can significantly improve the testability of your code.
  • Simplicity: DI can make code more simple, as it removes the need for objects to create their own dependencies. This can make code easier to understand and maintain.

Example:

protocol ExampleProtocols {
func getNames() async throws -> [String]
}
  1. protocol ExampleProtocols: This declares a protocol named ExampleProtocols. A protocol is a way to define a blueprint of methods, properties, and other requirements that a conforming type (such as a class, structure, or enumeration) must implement.
  2. func getNames() async throws -> [String]: This declares a method within the protocol named getNames. Here's a breakdown of the components:
  • func: This keyword is used to declare a function.
  • getNames(): This is the name of the function.
  • async: This keyword indicates that the function is asynchronous. In Swift, asynchronous programming is done using the async and await keywords, allowing non-blocking execution of code.
  • throws: This keyword indicates that the function can throw an error. When a function can potentially encounter an error, it must be marked with throws, and the caller must handle the potential errors using do-catch or propagate them up the call stack with try.
  • -> [String]: This specifies the return type of the function, which is an array of strings.
class ServiceManager: ExampleProtocols {

func getNames() async throws -> [String] {

var listOfNames: [String] = []

try await Task.sleep(nanoseconds: 1_000_000)

listOfNames = ["Bob", "John", "Apple"]

return listOfNames
}
}
  1. class ServiceManager: ExampleProtocols: This line declares a class named ServiceManager that explicitly states its conformance to the ExampleProtocols protocol. This means that the ServiceManager class is required to implement all the methods declared in the ExampleProtocols protocol.
  2. func getNames() async throws -> [String]: This is the implementation of the getNames method required by the ExampleProtocols protocol. Let's break down the implementation:
  • async: This keyword indicates that the function is asynchronous. It allows the function to use the await keyword inside its body.
  • throws: This keyword indicates that the function can throw an error. In this case, it aligns with the throws requirement from the ExampleProtocols protocol.
  • Task.sleep(nanoseconds: 1_000_000): This line asynchronously suspends the task (using await Task.sleep) for 1,000,000 nanoseconds (1 millisecond). This is likely used to simulate some asynchronous operation, such as fetching data from a remote service.
  • listOfNames = ["Bob", "John", "Apple"]: After the asynchronous operation, an array of names is assigned to listOfNames. In a real-world scenario, this could be the result of a network request, database query, or any other asynchronous operation.
  • return listOfNames: Finally, the array of names is returned.
class NameViewModel:ObservableObject{


let serviceManager:ServiceManager
@Published var listOfNames:[String] = []

init(serviceManager: ServiceManager) {
self.serviceManager = serviceManager
}

@MainActor
func getListOfNames() async{

do{
listOfNames = try await serviceManager.getNames()
}catch(let error){
print(error.localizedDescription)
}

}

}
  1. class NameViewModel: ObservableObject: This line declares a class named NameViewModel that conforms to the ObservableObject protocol. In SwiftUI, classes that conform to ObservableObject can be used as view models to store and manage the application's state.
  2. let serviceManager: ServiceManager: This is a property that holds an instance of the ServiceManager class. ServiceManager is a class responsible for fetching a list of names asynchronously, as suggested by the getNames method in your previous code.
  3. @Published var listOfNames: [String] = []: This is a property marked with the @Published property wrapper. When the value of listOfNames changes, it will automatically trigger updates to any SwiftUI views that are observing this property. This is a key mechanism for updating the user interface in response to changes in the underlying data.
  4. init(serviceManager: ServiceManager): This is the initializer for the NameViewModel class. It takes an instance of ServiceManager as a parameter and assigns it to the serviceManager property.
  5. @MainActor func getListOfNames() async: This is a method named getListOfNames marked with the @MainActor attribute. The @MainActor attribute ensures that this method is always called on the main thread, which is necessary for updating the UI in SwiftUI.
  6. do { ... } catch (let error) { ... }: This is a do-catch block used for error handling. Inside the do block, the listOfNames property is updated asynchronously by calling the getNames method on the serviceManager. If an error occurs during this asynchronous operation, it is caught in the catch block, and the error description is printed to the console.

Unit Test:

import XCTest
@testable import Aviation


class MockServiceManager: ServiceManager{

override func getNames() async throws -> [String] {
return ["name1","name2","name3","name4"]
}


}

final class NameViewModelTest: XCTestCase {

let nameViewModel:NameViewModel = NameViewModel(serviceManager: MockServiceManager())

func getTestNames() async{

await nameViewModel.getListOfNames()

XCTAssertEqual(nameViewModel.listOfNames, ["name1","name2","name3","name4"])

}

}
  1. import XCTest: This imports the XCTest framework, which is Apple's testing framework for Swift .
  2. @testable import Aviation: This allows the test code to access internal members of the Aviation module (including the NameViewModel and ServiceManager classes) for the purpose of testing. The @testable attribute is used to expose internal members to the test code.
  3. class MockServiceManager: ServiceManager: This declares a class named MockServiceManager that inherits from the ServiceManager class. It's a mock implementation of ServiceManager used for testing.
  4. override func getNames() async throws -> [String]: This overrides the getNames method of the ServiceManager class. Instead of performing an actual asynchronous operation, it returns a predefined array of names.
  5. final class NameViewModelTest: XCTestCase: This declares a final class NameViewModelTest that conforms to XCTestCase, which is the base class for all test cases in XCTest.
  6. let nameViewModel: NameViewModel = NameViewModel(serviceManager: MockServiceManager()): This creates an instance of NameViewModel for testing, using the MockServiceManager as the service manager. This allows the test to control the behavior of the service manager during the test.
  7. func getTestNames() async { ... }: This defines an asynchronous test function named getTestNames. Inside this function:
  8. await nameViewModel.getListOfNames(): This calls the getListOfNames method on the nameViewModel. The use of await indicates that the function is asynchronous and should wait for the asynchronous operation to complete.
  9. XCTAssertEqual(nameViewModel.listOfNames, ["name1", "name2", "name3", "name4"]): This asserts that the listOfNames property of the nameViewModel is equal to the expected array of names. If the actual result differs from the expected result, the test will fail.

--

--