Introducing Vaccine, a dependency injection framework
Since the introduction of property wrappers it became possible to do some great things with Swift, and the one that I mostly like is dependency injections.
I made this little code for my personal projects so it could be easier to test the behaviours of my services and view models without needing to pass the dependencies as arguments on their initializers.
This sample app was made with the intention of showing how simple it can be to inject mocked implementations and make the most out of the unit tests.
About the sample application
The app makes a request to chucknorris.io API and bring a random joke to the main screen.
If the user taps the refresh button, a loading animation occurs and another joke is loaded.
It is basically an UIViewController
that makes calls to a ViewModel
that also has a dependency to a Service
that makes API calls.
So... If I wanted to make this testable ideally I should create a Protocol
for my service and my view model, because then my view controller would use a reference to the view model protocol, and my view model a reference to my service protocol.
// Service protocol
protocol ChuckNorrisServicing {
func getRandomJoke(callback: @escaping (Result<ChuckNorrisResponse, Error>) -> Void)
}
// ViewModel protocol
protocol JokeViewModeling {
func getRandomJoke(callback: @escaping (Result<JokeModel, Error>) -> Void)
}
Implementing without the framework
Without the framework normally we would make initializers passing the default value and inside the tests we overwrite it by passing the mocked class.
final class JokeViewModel: JokeViewModeling {
private let service: ChuckNorrisServicing
init(service: ChuckNorrisServicing = ChuckNorrisService()) {
self.service = service
}
// ViewModel implementation...
}
And with the ViewController
we have a little problem, if I'm making a UIViewController
with nibs or storyboards I can't call the initializer directly. For simplicity in this case I'm instantiating the ViewModel directly on the variable.
There are quite a few problems when doing that, the most common one is to forget that the variable exists when we're making tests and don't overwrite it! If someone else grab that code to make changes it's not so clear why it was implemented like that either.
class ViewController: UIViewController {
var viewModel: JokeViewModeling = JokeViewModel()
// ViewController implementation...
}
Using Vaccine
The same result could be achieved by using Vaccine's property wrapper @Inject
. Then the code for our ViewModel and ViewController would look like that:
import Vaccine
final class JokeViewModel: JokeViewModeling {
@Inject(ChuckNorrisServicing.self) private var service
// ViewModel implementation...
}
class ViewController: UIViewController {
@Inject(JokeViewModeling.self) private var viewModel
// ViewController implementation...
}
Although we are declaring the properties as var
's and not let
's, they can't be overwritten after being initialized because inside the property wrapper they are constants. Property wrappers need to be declared as var
's since they are intended to wrap the current value and do some logic behind it.
One of the coolest things is that we can set those variables on UIViewController
's and not bother to implement those nib/storyboards initializers! 😅
We should register our dependencies on the starting point of our application, the AppDelegate
. If dependencies are not defined, the application will crash and inform you which classes you forgot to register.
import Vaccine
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Vaccine.setCure(for: ChuckNorrisServicing.self, with: ChuckNorrisService())
Vaccine.setCure(for: JokeViewModeling.self, with: JokeViewModel())
return true
}
}
Testing
Now that we are using Vaccine
we can easily set our mock class to test the ViewModel behaviour.
// Service mock class
class ServiceMock: ChuckNorrisServicing {
var expectedJoke: ChuckNorrisResponse?
var expectedError: Error?
func getRandomJoke(callback: @escaping (Result<ChuckNorrisResponse, Error>) -> Void) {
if let joke = expectedJoke {
callback(.success(joke))
}
if let error = expectedError {
callback(.failure(error))
}
}
}
Here is how the tests will work:
import XCTest
@testable import VaccineExample
import Vaccine
class VaccineExampleTests: XCTestCase {
private var serviceMock: ServiceMock!
override func setUpWithError() throws {
serviceMock = ServiceMock()
Vaccine.setCure(for: ChuckNorrisServicing.self, with: self.serviceMock)
}
override func tearDownWithError() throws {
serviceMock = nil
}
func testViewModelSuccess() throws {
let response = ChuckNorrisResponse(iconUrl: "", id: "", url: "", value: "My name is Chuck Norris")
serviceMock.expectedJoke = response
let testExpectation = expectation(description: "Request ends")
JokeViewModel().getRandomJoke { result in
defer { testExpectation.fulfill() }
switch result {
case .success(let model):
XCTAssertTrue(model.joke == response.value)
case .failure(let error):
XCTFail("Test failed with error: \(error.localizedDescription)")
}
}
wait(for: [testExpectation], timeout: 10.0)
}
func testViewModelError() throws {
serviceMock.expectedError = URLError(.unknown)
let testExpectation = expectation(description: "Request ends")
JokeViewModel().getRandomJoke { result in
defer { testExpectation.fulfill() }
switch result {
case .success:
XCTFail("Test should fail")
case .failure(let error):
XCTAssertEqual(error as! URLError, URLError(.unknown))
}
}
wait(for: [testExpectation], timeout: 10.0)
}
}
Wrap up
And that's it! With Vaccine
you can also set your dependencies as singletons and/or do more complex stuff by calling setCure
with closures!
Check out Vaccine, pull requests are very welcome! 😃