How to test and mock property wrappers in Swift?

2k Views Asked by At

Let's say I have a very common use case for a property wrapper using UserDefaults.

@propertyWrapper
struct DefaultsStorage<Value> {
    private let key: String
    private let storage: UserDefaults
    
    var wrappedValue: Value? {
        get {
            guard let value = storage.value(forKey: key) as? Value else {
                return nil
            }
            
            return value
        }
        
        nonmutating set {
            storage.setValue(newValue, forKey: key)
        }
    }
    
    init(key: String, storage: UserDefaults = .standard) {
        self.key = key
        self.storage = storage
    }
}

I am now declaring an object that would hold all my values stored in UserDefaults.

struct UserDefaultsStorage {
    @DefaultsStorage(key: "userName")
    var userName: String?
}

Now when I want to use it somewhere, let's say in a view model, I would have something like this.

final class ViewModel {
    func getUserName() -> String? {
        UserDefaultsStorage().userName
    }
}

Few questions arise here.

  1. It seems that I am obliged to use .standard user defaults in this case. How to test that view model using other/mocked instance of UserDefaults?
  2. How to test that property wrapper using other/mocked instance of UserDefaults? Do I have to create a new type that is a clean copy of the above's DefaultsStorage, pass mocked UserDefaults and test that object?
struct TestUserDefaultsStorage {
    @DefaultsStorage(key: "userName", storage: UserDefaults(suiteName: #file)!)
    var userName: String?
}
2

There are 2 best solutions below

2
On

As @mat already mentioned in the comments, you need a protocol to mock UserDefaults dependency. Something like this will do:

protocol UserDefaultsStorage {
    func value(forKey key: String) -> Any?
    func setValue(_ value: Any?, forKey key: String)
}

extension UserDefaults: UserDefaultsStorage {}

Then you can change your DefaultsStorage propertyWrapper to use a UserDefaultsStorage reference instead of UserDefaults:

@propertyWrapper
struct DefaultsStorage<Value> {
    private let key: String
    private let storage: UserDefaultsStorage

    var wrappedValue: Value? {
        get {
            return storage.value(forKey: key) as? Value
        }
        nonmutating set {
            storage.setValue(newValue, forKey: key)
        }
    }

    init(key: String, storage: UserDefaultsStorage = UserDefaults.standard) {
        self.key = key
        self.storage = storage
    }
}

After that a mock UserDefaultsStorage might look like this:

class UserDefaultsStorageMock: UserDefaultsStorage {
    var values: [String: Any]

    init(values: [String: Any] = [:]) {
        self.values = values
    }

    func value(forKey key: String) -> Any? {
        return values[key]
    }

    func setValue(_ value: Any?, forKey key: String) {
        values[key] = value
    }
}

And to test DefaultsStorage, pass an instance of UserDefaultsStorageMock as its storage parameter:

import XCTest

class DefaultsStorageTests: XCTestCase {
    class TestUserDefaultsStorage {
        @DefaultsStorage(
            key: "userName",
            storage: UserDefaultsStorageMock(values: ["userName": "TestUsername"])
        )
        var userName: String?
    }
    
    func test_userName() {
        let testUserDefaultsStorage = TestUserDefaultsStorage()
        
        XCTAssertEqual(testUserDefaultsStorage.userName, "TestUsername")
    }
}
0
On

This might not be the best solution, however, I haven't figured out a way to inject UserDefaults that use property wrappers into a ViewModel. If there is such an option, then gcharita's proposal to use another protocol would be a good one to implement.

I used the same UserDefaults in the test class as in the ViewModel. I save the original values before each test and restore them after each test.

class ViewModelTests: XCTestCase {
    
    private lazy var userDefaults = newUserDefaults()
    private var preTestsInitialValues: PreTestsInitialValues!

    override func setUpWithError() throws {
        savePreTestUserDefaults()
    }
    
    override func tearDownWithError() throws {
        restoreUserDefaults()
    }
    
    private func newUserDefaults() -> UserDefaults.Type {
        return UserDefaults.self
    }
    
    private func savePreTestUserDefaults() {
        preTestsInitialValues = PreTestsInitialValues(userName: userDefaults.userName)
    }
    
    private func restoreUserDefaults() {
        userDefaults.userName = preTestsInitialValues.userName
    }

    func testUsername() throws {
       //"inject" User Defaults with the desired values
        let username = "No one"
        userDefaults.userName = username
        
        let viewModel = ViewModel()
        let usernameFromViewModel = viewModel.getUserName()
        XCTAssertEqual(username, usernameFromViewModel)
    }
}

struct PreTestsInitialValues {
    let userName: String?
}