Now that we know the different types of Dependency Injections, let's check how testable our classes are. In this example, we are not using protocols, yet.
class APIClient {}
final class MyAPIClient: APIClient {
func execute() async -> Result<Void, Error> {
// API Call
}
}
final class MyRepository {
private var service: MyAPIClient
init(service: MyAPIClient) {
self.service = service
}
func someFunction() async -> Result<Void, Error> {
return await service.execute()
}
}
If we want to add unit test for successful and failure events for MyRepository class.
import XCTest
@testable import Project_Name
final class Project_NameTests: XCTestCase {
private var sut: MyRepository!
override func setUpWithError() throws {
let service = MyAPIClient()
sut = MyRepository(service: service)
}
func testSomeFunctionSuccess() async {
let result = await sut.someFunction()
switch result {
case .success:
/// ...
case .failure(_):
XCTFail(("Should not fail"))
}
}
func testSomeFunctionFailedWithError() async {
let result = await sut.someFunction()
switch result {
case .success:
XCTFail("Should not succeed")
case .failure(let error):
// You may check if it's returning specific error that you expect to receive
}
}
}
We are able to inject our Service dependency into our Repository Class. However, the problem with this kind of approach is that when we test, we are calling the concrete MyAPIClient class, which means, we are going to be calling real API in our testing. Which is not a good.
It is not okay to be calling real API methods when testing because:
We can bombard our servers with these calls whenever we test, how much more if we have CI/CD in place that may get triggered to run tests in our PRs, and how many PRs are possibly open at the same time.
We will add to the traffic
If the server is down, it will affect our tests, also we cannot simply test pass/failed state because we need the API to return a success/error, which we cannot predict when it'll happen
If we are tracking api calls for specific events, we will be adding a wrong / fake data to it
Unit Tests are expected to finish very quickly, network condition can possibly affect our test cases
What we need to do is actually create mocks. But before we can do that, we have to solve this first:
If you notice, our sut (system under test) which is the MyRepository class accepts a concrete MyAPIClient class during initialization. Why do we need MyAPIClient class? It is so that we can call the execute() function in this class, which will call the API for us.
How can we call execute() function without using a concrete class?
The answer is by using interfaces / protocols. Before, our MyRepository class is dependent to MyAPIClient class.
But once we start using interfaces / protocols, we now have to think differently. Our MyRepository class needs something, and that is to be able to call this method execute() that will be performed asynchronously and will give it a response, either a success or failure.
In this diagram, MyAPIClient class is nowhere in sight. It's because, MyRepository does not need it anymore. MyRepository only needs this protocol that has the function it's looking for.
But, how will this interface / protocol call the API methods? It doesn't. It can't do that. A class has to implement that protocol. And in this case, we will let our MyAPIClient implement that protocol.
The control has now been reversed (a.k.a. inversion of control). How?
Before, MyRepository has no control, it can only call whatever the MyAPIClient can provide and expose to other classes. However, in this case, MyRepository decides what it needs (through a protocol), and to the class that will implement that protocol has to comply what was asked from it.
In this diagram, MyRepository is basically telling it that "I need this method called execute that will be performed asynchronously and will give me a response, either a success or failure," and "I don't care how it's done, or who does it." MyRepository now has the control.
Let's see how our codes will look now that we are going to use interfaces / protocols.
class APIClient {}
protocol MyAPIClientProtocol {
func execute() async -> Result<Void, Error>
}
final class MyAPIClient: APIClient, MyAPIClientProtocol {
func execute() async -> Result<Void, Error> {
return .success(())
}
}
final class MyRepository {
private var service: MyAPIClientProtocol
init(service: MyAPIClientProtocol) {
self.service = service
}
func someFunction() async -> Result<Void, Error> {
return await service.execute()
}
}
Take note, MyRepository is now accepting a Protocol, not a concrete class. This code now reflects the diagram above.
Now, we can create mocks in our unit test. Remember what MyRepository said? It doesn't care how it's done, or who does it.
private var sut: MyRepository!
override func setUpWithError() throws {
// We will no longer set up our sut here because we need to pass specific mock for specific scenario - pass / fail
}
Testing a successful scenario with our mock class.
func testSomeFunctionSuccess() async {
let service = SuccessfulServiceMock()
sut = MyRepository(service: service)
let result = await sut.someFunction()
switch result {
case .success:
XCTAssertTrue(true)
case .failure(_):
XCTFail("Should not fail")
}
}
private class SuccessfulServiceMock: MyAPIClientProtocol {
func execute() async -> Result<Void, Error> {
return .success(())
}
}
Testing a failure scenario with our mock class.
func testSomeFunctionFailedWithError() async {
let service = FailedServiceMock()
sut = MyRepository(service: service)
let result = await sut.someFunction()
switch result {
case .success:
XCTFail("Should not succeed")
case .failure(let error):
XCTAssertTrue(true)
// You may check if it's returning specific error that you expect to receive
//if let error = error as? MyError {
// XCTAssertEqual(MyError.invalid, error)
//}
}
}
private class FailedServiceMock: MyAPIClientProtocol {
func execute() async -> Result<Void, Error> {
return .failure(MyError.invalid)
}
}
Now, we have decoupled our classes MyRepository and MyAPIClient, we have inverted the control, and we have made our class testable! We even made it scalable! How?
Imagine, for some reasons, we are going to update our API, so we now have a version2. We want to update from using MyAPIClient to another class that contains the version2. MyRepository's needs, however, are still the same. So we only need to update the API class.
In this diagram, we can easily replace MyAPIClient with another class as long as this new class simply conforms to the interface / protocol. And instead of injecting MyAPIClient() to our MyRepository(service:), we can inject MyAPIClientV2(). We just saved ourselves from a lot of code changes - which will possibly break our other classes.
I hope I was able to impart some knowledge about Dependency Injection. This part 2 actually covered other areas, not just Dependency Injection, but these are necessary concepts to be able to understand fully how Dependency Injection and interfaces/protocols help us in making loosely-coupled, scalable and testable classes.
ความคิดเห็น