如何在Swift中测试和模拟属性包装器?

5
假设我有一个使用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
    }
}

我现在声明了一个对象来保存我在UserDefaults中存储的所有值。

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

现在如果我想在某个地方使用它,比如说在视图模型中,我会有这样的代码。
final class ViewModel {
    func getUserName() -> String? {
        UserDefaultsStorage().userName
    }
}

这里有几个问题。

  1. 在这种情况下,似乎我必须使用.standard用户默认设置。如何使用其他/模拟的UserDefaults实例来测试视图模型?
  2. 如何使用其他/模拟的UserDefaults实例来测试该属性包装器?我是否必须创建一个新类型,该类型是上述DefaultsStorage的干净副本,传递模拟的UserDefaults并测试该对象?
struct TestUserDefaultsStorage {
    @DefaultsStorage(key: "userName", storage: UserDefaults(suiteName: #file)!)
    var userName: String?
}

3
通常用来嘲弄的关键在于协议... - matt
你的意思是模拟整个 UserDefaultsStorage 吗? - gasho
我是指模拟UserDefaults。 - matt
很抱歉,但这恰好是我未能达到的目标。 - gasho
1
我确实想要模拟UserDefaults。我只需通过将不同的“suiteName”传递给初始化程序即可实现。这对我来说已经足够了。我不认为我需要一个协议。我只是不确定如何使“DefaultsStorage”与另一个UserDefaults实例一起工作。 - gasho
显示剩余2条评论
2个回答

3
如 @mat 在评论中提到的那样,你需要一个协议来模拟 UserDefaults 依赖项。如下所示的示例代码可以实现:
protocol UserDefaultsStorage {
    func value(forKey key: String) -> Any?
    func setValue(_ value: Any?, forKey key: String)
}

extension UserDefaults: UserDefaultsStorage {}

那么,您可以将 DefaultsStorage 属性包装器更改为使用 UserDefaultsStorage 引用而不是 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
    }
}

接下来,一个模拟的UserDefaultsStorage可能看起来像这样:

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
    }
}

为了测试DefaultsStorage,请将UserDefaultsStorageMock的实例作为其存储参数传递:

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

3
你好,感谢回答。这完全有道理,但我认为这并没有回答我的第一个问题。如果不使用.standard用户默认值,您将如何测试使用此存储的视图模型? - gasho
@gasho 在我的示例中,我测试了 DefaultsStorage。为了使您的视图模型可测试,您必须引入一个新的协议,DefaultsStorage 应符合该协议。您的视图模型应该引用该协议而不是直接引用 DefaultsStorage,然后您将能够模拟 DefaultsStorage 并将其注入到您的视图模型中。通常,当您想要测试某些内容时,您必须能够模拟其所有依赖项。 - gcharita

0

这可能不是最好的解决方案,但我还没有找到一种将使用属性包装器的UserDefaults注入到ViewModel中的方法。如果有这样的选项,那么gcharita提出的使用另一个协议的建议是一个很好的实现。

我在测试类中使用了与ViewModel中相同的UserDefaults。我在每个测试之前保存原始值,并在每个测试之后恢复它们。

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接