Test iOS code that relies on a shared instance

Learn how to test iOS that relies on shared instance

Test iOS code that relies on a shared instance
Test iOS code that relies on a shared instance

As an experienced iOS developer, a question you may encounter during an interview could be:

“What is your approach to testing this view model that directly references the user default shared instance?”

class ViewModel {
    func saveUserName(name: String) {
        UserDefaults.standard.set(name, forKey: "username")
    }
}

While you may answer something like below, you will see that this solution has its problems.

class TestCode: XCTestCase {
    func test() {
        let viewModel = ViewModel()
        viewModel.saveUserName(name: "the username")
        XCTAssertEqual("the username", UserDefaults.standard.string(forKey: "username"))
    }
}

Problem

The issue at hand is that the view model has a strong dependency on the user default shared instance, which makes it challenging to conduct isolated testing. Presently, tests must be aware of user defaults and must reset its values before each test to prevent inaccurate results. Furthermore, this test is considered an integration test rather than a unit test.

Solution :

The objective is to store the username without the requirement of manipulating user defaults. To accomplish this, a new component is introduced that specifically manages user default mechanisms for user data, instead of calling the singleton within the view model.

protocol UserStorage {
    func save(name: String)
}
class UserStorageWithUserDefaults: UserStorage {
    private let usernameKey = "username"
    
    func save(name: String) {
        UserDefaults.standard.set(name, forKey: usernameKey)
    }
}

Another issue resolved by this solution is the need for the caller of the view model (such as a view controller) to be aware of and manage keys for user default key values which is not its responsibility. With the introduction of the UserStorageWithUserDefaults component, this responsibility is shifted entirely to the component.

The view model can now use this new UserStorage component :

class ViewModel {
    private let userStorage: UserStorage
    
    init(userStorage: UserStorage) {
        self.userStorage = userStorage
    }
    
    func saveUserName(name: String) {
        userStorage.save(name: name)
    }
}

As demonstrated, the UserStorage component is now injected into the view model. Therefore, we must modify the code that calls the ViewModel, as follows. From :

let ViewModel = ViewModel() viewModel.saveUserName(name: "the username")

To this version :

let viewModel = ViewModel(userStorage: UserStorageWithUserDefaults())
viewModel.saveUserName(name: "the username")

Finally, for test code, we can leverage a dedicated implementation of the UserStorage protocol that exists solely in memory and does not depend on any external framework:

class InMemoryUserStorage: UserStorage {
    var name = ""
    func save(name: String) {
        self.name = name
    }
}
class TestCode: XCTestCase {
    func test() {
        let memory = InMemoryUserStorage()
        let viewModel = ViewModel(userStorage: memory)
        
        viewModel.saveUserName(name: "the username")
        
        XCTAssertEqual("the username", memory.name)
    }
}