Mocking
Mocking
Up until this point, we looked at very simple, “idealized” components. Even our LoginViewModel
that we used as an example is greatly simplified. In reality, no login functionality can work without a Networking service dependency.
Real life application components are highly interconnected with each other. View Models depend on a bunch of services to handle the business logic, and each service might in turn depend on several other services to do their job.
With this in mind, let’s increase the complexity of our LoginViewModel
slightly, by adding more functionality:
@Observable
final class LoginViewModel: LoginViewModelProtocol {
// MARK: - Dependencies
private let authenticationService = AuthenticationService()
// MARK: - Properties
var email: String = ""
var password: String = ""
var token: String?
var error: Error?
// MARK: - Getters
var isEmailValid: Bool {
email.contains("@") &&
email.contains(".") &&
email.count <= 64 &&
email.isMatchingRegex("^[A-Za-z0-9]")
}
var isPasswordValid: Bool {
password.count > 8
}
var isLoginButtonEnabled: Bool {
isEmailValid && isPasswordValid
}
// MARK: - Events
func onLogin() async {
do {
let token = try await authenticationService.login(
email: email,
password: password
)
onSuccess(with: token)
} catch {
onFailure(with: error)
}
}
// MARK: - Helpers
private func onSuccess(with token: String) {
self.token = token
}
private func onFailure(with error: Error) {
self.error = error
}
}
In this new version, our LoginViewModel
also has a method called onLogin
, which allows used to perform an authentication when they tap on the “Login” button on the UI. As a result, our view model now depends on an AuthenticationService
for its authentication logic.
And this is where our problem starts. One of the main principles of unit testing is isolation - meaning that each component of our application must be tested independently, without accessing other parts of the application.
Since our view model has a direct connection with AuthenticationService
, it is impossible to test the onLogin
method without touching the AuthenticationService
logic as well. Why is this an issue? Our authentication service perform API requests, which means that if the backend is down, our unit tests will fail as well.
As you can imagine, this is not great. We care about testing our view model logic, and only that. We don’t care about anything else outside of this scope. So if our tests fail because of an external factor, they aren’t working as expected. This also ties into a different principle of unit testing, which is reliability. If I run the test 100 times, I should always expect the same result. As you can imagine, this is hard to do when you can’t control the external dependencies of your component.
This is where protocols and dependency injection come into place. Our goal is to ensure that when writing unit tests, we don’t access the “real” authentication implementation, but rather a fake, mocked implementation.
Adding Dependency Injection
First of all, let’s add dependency injection. The purpose of dependency injection is that instead of hardcoding our dependencies directly inside of our components, we pass them via initializers. This will allow us to pass different versions of our authentication service when we initialize our SUT for testing.
Basically, instead of our current implementation:
@Observable
final class LoginViewModel: LoginViewModelProtocol {
// MARK: - Dependencies
private let authenticationService = AuthenticationService()
// ...
}
We change it as such:
@Observable
final class LoginViewModel: LoginViewModelProtocol {
// MARK: - Dependencies
private let authenticationService: AuthenticationService
// MARK: - Initializers
init(authenticationService: AuthenticationService) {
self.authenticationService = authentication
}
}
Adding Protocols
Even though we improved our code by adding dependency injection, our LoginViewModel
still only accepts an AuthenticationService
implementation, which relies on real API calls. What we need to do is allow LoginViewModel
to accept any authentication service, as long as it has the required login method.
This is where protocols come in. Protocols act as a contract - they specify what something does, but now how it does it. If our LoginViewModel
depends on an protocol instead of a concrete implementation, we basically say that our view model will accept any class or struct as long as it adopts the AuthenticationServiceProtocol
.
In other words, as long as we have the required login
method, we don’t care who implements the protocol and what is passed as a parameter in the LoginViewModel
initializer.
In conclusion, by replacing our implementation with a protocol, we can easily create an AuthenticationServiceMock
with our custom behaviour, and replace it in our unit tests.
Let’s start by defining our protocol:
import Foundation
protocol AuthenticationServiceProtocol {
func login(email: String, password: String) async throws -> String
}
Then, let’s make our AuthenticationServiceImplementation
adopt the protocol:
import Foundation
final class AuthenticationService: AuthenticationServiceProtocol {
// MARK: - Dependencies
private let apiService: APIServiceProtocol
// MARK: - Initializers
init(apiService: APIServiceProtocol) {
self.apiService = apiService
}
// MARK: - Protocol Conformance
func login(email: String, password: String) async throws -> String {
let request = LoginRequest(email: email, password: password)
return try await apiService.send(request)
}
}
Finally, in our view model, let’s replace the implementation with the protocol definition:
@Observable
final class LoginViewModel: LoginViewModelProtocol {
// MARK: - Dependencies
private let authenticationService: AuthenticationServiceProtocol
// MARK: - Initializers
init(authenticationService: AuthenticationServiceProtocol) {
self.authenticationService = authenticationService
}
// MARK: - Properties
var email: String = ""
var password: String = ""
var token: String?
var error: Error?
// MARK: - Getters
var isEmailValid: Bool {
email.contains("@") &&
email.contains(".") &&
email.count <= 64 &&
email.isMatchingRegex("^[A-Za-z0-9]")
}
var isPasswordValid: Bool {
password.count > 8
}
var isLoginButtonEnabled: Bool {
isEmailValid && isPasswordValid
}
// MARK: - Events
func onLogin() async {
do {
let token = try await authenticationService.login(
email: email,
password: password
)
onSuccess(with: token)
} catch {
onFailure(with: error)
}
}
// MARK: - Helpers
private func onSuccess(with token: String) {
self.token = token
}
private func onFailure(with error: Error) {
self.error = error
}
}
With this, our implementation is done. Now, in our test target - we can create a mocked authentication service implementation that does not rely on a real API service. This mocked implementation will return fake responses, depending on our test requirements:
import Foundation
final class AuthenticationServiceMock: AuthenticationServiceProtocol {
var loginTask: Task<Result<String, AuthenticationServiceError>, Never> = Task {
.failure(.requestFailed)
}
func login(email: String, password: String) async throws -> String {
try await loginTask.value.get()
}
}
This mocked implementation allows us to set the expected return value for our login
method - either a .success(String)
or a .failure(AuthenticationServiceError)
, which we can use to simulate a successful or failed API response.
Finally, let’s update our tests:
import XCTest
@testable import CreativityHub
final class LoginViewModelTests: XCTestCase {
// MARK: - Properties
private var authenticationService: AuthenticationServiceMock!
private var sut: LoginViewModel!
// MARK: - Test Setup
override func setUp() async throws {
try await super.setUp()
authenticationService = AuthenticationServiceMock()
sut = await LoginViewModel(authenticationService: authenticationService)
}
override func tearDown() async throws {
authenticationService = nil
sut = nil
try await super.tearDown()
}
// MARK: - Test Cases
@MainActor
func test_givenSuccessfulResponse_whenOnLogin_thenReturnAccessToken() async {
// Given
authenticationService.loginTask = Task {
.success("ACCESS_TOKEN")
}
// When
await sut.onLogin()
// Then
XCTAssertEqual(sut.token, "ACCESS_TOKEN")
}
@MainActor
func test_givenFailedResponse_whenOnLogin_thenThrowException() async {
// Given
authenticationService.loginTask = Task {
.failure(.requestFailed)
}
// When
await sut.onLogin()
// Then
XCTAssertEqual(sut.error as? AuthenticationServiceError, .requestFailed)
}
}