SwiftUI Deep Dive: Dependency Injection Strategies and Testing Techniques
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]
}
protocol ExampleProtocols
: This declares a protocol namedExampleProtocols
. 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.func getNames() async throws -> [String]
: This declares a method within the protocol namedgetNames
. 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 theasync
andawait
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 withthrows
, and the caller must handle the potential errors usingdo-catch
or propagate them up the call stack withtry
.-> [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
}
}
class ServiceManager: ExampleProtocols
: This line declares a class namedServiceManager
that explicitly states its conformance to theExampleProtocols
protocol. This means that theServiceManager
class is required to implement all the methods declared in theExampleProtocols
protocol.func getNames() async throws -> [String]
: This is the implementation of thegetNames
method required by theExampleProtocols
protocol. Let's break down the implementation:
async
: This keyword indicates that the function is asynchronous. It allows the function to use theawait
keyword inside its body.throws
: This keyword indicates that the function can throw an error. In this case, it aligns with thethrows
requirement from theExampleProtocols
protocol.Task.sleep(nanoseconds: 1_000_000)
: This line asynchronously suspends the task (usingawait 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 tolistOfNames
. 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)
}
}
}
class NameViewModel: ObservableObject
: This line declares a class namedNameViewModel
that conforms to theObservableObject
protocol. In SwiftUI, classes that conform toObservableObject
can be used as view models to store and manage the application's state.let serviceManager: ServiceManager
: This is a property that holds an instance of theServiceManager
class.ServiceManager
is a class responsible for fetching a list of names asynchronously, as suggested by thegetNames
method in your previous code.@Published var listOfNames: [String] = []
: This is a property marked with the@Published
property wrapper. When the value oflistOfNames
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.init(serviceManager: ServiceManager)
: This is the initializer for theNameViewModel
class. It takes an instance ofServiceManager
as a parameter and assigns it to theserviceManager
property.@MainActor func getListOfNames() async
: This is a method namedgetListOfNames
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.do { ... } catch (let error) { ... }
: This is a do-catch block used for error handling. Inside thedo
block, thelistOfNames
property is updated asynchronously by calling thegetNames
method on theserviceManager
. If an error occurs during this asynchronous operation, it is caught in thecatch
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"])
}
}
import XCTest
: This imports the XCTest framework, which is Apple's testing framework for Swift .@testable import Aviation
: This allows the test code to access internal members of theAviation
module (including theNameViewModel
andServiceManager
classes) for the purpose of testing. The@testable
attribute is used to expose internal members to the test code.class MockServiceManager: ServiceManager
: This declares a class namedMockServiceManager
that inherits from theServiceManager
class. It's a mock implementation ofServiceManager
used for testing.override func getNames() async throws -> [String]
: This overrides thegetNames
method of theServiceManager
class. Instead of performing an actual asynchronous operation, it returns a predefined array of names.final class NameViewModelTest: XCTestCase
: This declares a final classNameViewModelTest
that conforms toXCTestCase
, which is the base class for all test cases in XCTest.let nameViewModel: NameViewModel = NameViewModel(serviceManager: MockServiceManager())
: This creates an instance ofNameViewModel
for testing, using theMockServiceManager
as the service manager. This allows the test to control the behavior of the service manager during the test.func getTestNames() async { ... }
: This defines an asynchronous test function namedgetTestNames
. Inside this function:await nameViewModel.getListOfNames()
: This calls thegetListOfNames
method on thenameViewModel
. The use ofawait
indicates that the function is asynchronous and should wait for the asynchronous operation to complete.XCTAssertEqual(nameViewModel.listOfNames, ["name1", "name2", "name3", "name4"])
: This asserts that thelistOfNames
property of thenameViewModel
is equal to the expected array of names. If the actual result differs from the expected result, the test will fail.