Explore Testing With Singletons (swift)
Testing Singletons
Often we see singletons being used left and right in our codebase and you wonder how to test them here are some ways you could do that.
lets take an example of a LoginViewController that uses an api client to login and our goal is to test this api client somehow without making a real api request.You can learn more about singleton in part 1. Same concepts apply in SwiftUI.
this is our main code from the loginVC it’s pretty self-explanatory we call our login method from the APIClient and complete with a LoggedInUser. The question becomes how do you then make the LoginVC testable
import Foundation
import UIKit
struct LoggedInUser {
let name:String
}
class APIClient {
static let shared = APIClient()
private init(){}
func login(completion: (LoggedInUser) -> Void) {
print("ApiClient loginCalled")
completion(LoggedInUser(name: "abdul"))
}
}
class LoginVC:UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
didTapLoginButton()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@MainActor
func didTapLoginButton() {
APIClient.shared.login { user in
print(user)
}
}
}
You can test Singletons using each of the 3 three methods below.
1- subclassing + property injection
in this part we subclass the ApiClient with MockApiClient and override the login method to provide our implementation so when you write a unit test for this ApiClient can swap the ApiClient with MockApiClient.
The drawback with this approach is that our initalizer is not private and so we can create as many ApiClients as we want so it’s not a singleton according to the GOF.
import UIKit
struct LoggedInUser{}
class ApiClient {
static let instance = ApiClient()
init() {}
func login(completion: (LoggedInUser) -> Void) {
print("ApiClient loginCalled")
completion(LoggedInUser(name: "abdul"))
}
}
class MockApiClient: ApiClient {
override func login(completion: (LoggedInUser) -> Void) {
// override login behavior here for tests
print("MockApiClient loginCalled")
completion(LoggedInUser(name: "test"))
}
}
private class LoginViewController {
var api = ApiClient.instance
func didTapLoginButton() -> LoggedInUser? {
var loggedInUser :LoggedInUser?
api.login() { user in
loggedInUser = user
}
return loggedInUser
}
//...viewcontroller methods
}
private final class SingletonSubclassTests: XCTestCase {
func testDidTapLogin(){
let loginVc = LoginViewController()
loginVc.api = MockApiClient()
XCTAssertEqual(loginVc.didTapLoginButton()?.name,"test")
}
}
2- Protocol Mocking
in this part, the NetworkClient conforms to the LoginProtocol and so any other instance that conforms to the same protocol can be swapped in place of the NetworkClient when used inside LoginView.
notice that we do not have to make our initializer public in this case and we follow the GOF book by the word
import XCTest
import Foundation
struct LoggedInUser {
let name:String
}
protocol loginProtocol {
func login(completion: (LoggedInUser) -> Void)
}
class NetworkClient:loginProtocol{
static let shared = NetworkClient()
private init(){}
func login(completion: (LoggedInUser) -> Void) {
completion(LoggedInUser(name: "abdul"))
}
}
class MockNetworkClient:loginProtocol{
func login(completion: (LoggedInUser) -> Void) {
completion(LoggedInUser(name: "test"))
}
}
class LoginViewController {
let api : loginProtocol
init(api: loginProtocol) {
self.api = api
}
func didTapLoginButton() -> LoggedInUser? {
var loggedInUser :LoggedInUser?
api.login() { user in
loggedInUser = user
}
return loggedInUser
}
//...viewcontroller methods
}
final class SingletonProtocolTests: XCTestCase {
func testDidTapLogin(){
let loginVc = LoginView(api: MockNetworkClient())
XCTAssertEqual(loginVc.didTapLoginButton()?.name,"test")
}
}
3- Closure Injection using a type alias
this is the same as protocols but instead, we use closures so we copy the signature of the login method from HTTPClient to a type alias LoginLoader and use that as the property for our LoginVC. This way we can swap our http client with any other implementation with the same function signature as the httpClient. We write our mock implementation in the test.
import UIKit
struct LoggedInUser{}
class HttpClient {
static let shared = HttpClient()
private init(){}
func login(completion: (LoggedInUser) -> Void) {
print("ApiClient loginCalled")
completion(LoggedInUser(name: "abdul"))
}
}
// ViewController
typealias LoginLoader = ((LoggedInUser) -> Void) -> Void
class LoginViewController {
var login: LoginLoader?
func didTapLoginButton() -> LoggedInUser? {
var loggedInUser :LoggedInUser?
login? { user in
loggedInUser = user
}
return loggedInUser
}
//...viewcontroller methods
}
class SingletonClosureTest:XCTestCase {
func testDidTapLoginButton(){
let loginVc = LoginVC()
loginVc.login = { completion in
print("Mock loginCalled")
completion(LoggedInUser(name: "test"))
}
XCTAssertEqual(loginVc.didTapLoginButton()?.name,"test")
}
}
we can use these techniques inside our tests to mock the singleton and assert our custom behavior without calling an HTTP request.
Singletons are something you see in the codebase all the time and it’s important to know how to test code that uses them so you can refactor them later to use better patterns as Singletons used in a wrong way can be a anti-pattern.
GithubLink: https://github.com/frodo10messi/SingletonTests