How to test and mock property wrappers in Swift?

Viewed 1157

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?
}
1 Answers

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")
    }
}
Related