如何使用全局结构进行单元测试?

How to Unit-Test with global structs?

我很清楚在 UnitTest 中你

  1. 生成一个输入属性
  2. 将此 属性 传递给您要测试的方法
  3. 将结果与您的预期结果进行比较

但是,如果您有一个全局结构,例如游戏经验值和游戏关卡有私人设置器,不能修改。当应用程序启动时,我会自动从 UserDefaults 加载这些数据。当您无法更改输入时,如何测试访问该全局结构的方法?

示例:

import UIKit

//Global struct with private data
struct GameStatus {
    private(set) static var xp: Int = 0
    private(set) static var level: Int = 0

    /// Holds all winning states
    enum MyGameStatus {
        case hasNotYetWon
        case hasWon
    }

    /// Today's game state of the user against ISH
    static var todaysGameStatus: MyGameStatus {
        if xp >= 100 {
            return .hasWon
        } else {
            return .hasNotYetWon
        }
    }

    func restoreXpAndLevel() {
        // reads UserData value
    }

    func increaseXp(for: Int) {
        //...
    }
}

// class with methods to test
class LevelView: UIView {

    enum LevelState {
        case showStart
        case showCountdown
        case showFinalCuontdown
    }

    var state: LevelState {
        if GameStatus.xp > 95 {
            return .showFinalCuontdown
        } else if GameStatus.xp > 90 {
            return .showCountdown
        }
        return .showStart
    }

    //...configurations depending on the level
}

首先,LevelView 看起来逻辑太多了。视图的作用是显示模型数据。它不包括像 GameStatus.xp > 95 这样的业务逻辑。这应该在别处完成并设置到视图中。

接下来,为什么GameStatus是静态的?这只会使事情复杂化。改变时将 GameStatus 传递给视图。这是视图控制器的工作。视图只是绘制东西。如果在您看来任何东西确实是可单元测试的,那么它可能不应该出现在视图中。

最后,您遇到的问题是用户默认设置。因此,将该部分提取到通用 GameStorage 中。

protocol GameStorage {
    var xp: Int { get set }
    var level: Int { get set }
}

现在将 UserDefaults 设为 GameStorage:

extension UserDefaults: GameStorage {
    var xp: Int {
        get { /* Read from UserDefaults */ return ... }
        set {  /* Write to UserDefaults */ }
    }
    var level: Int {
        get { /* Read from UserDefaults */ return ... }
        set {  /* Write to UserDefaults */ }
    }
}

为了测试,创建一个静态的:

struct StaticGameStorage: GameStorage {
    var xp: Int
    var level: Int
}

现在,当您创建 GameStatus 时,将存储传递给它。但是你可以给它一个默认值,所以你不必一直传递它

class GameStatus {
    private var storage: GameStorage

    // A default parameter means you don't have to pass it normally, but you can
    init(storage: GameStorage = UserDefaults.standard) {
        self.storage = storage
    }

这样,xp 和等级就可以直接传递到存储空间了。无需特殊的 "load the storage now" 步骤。

private(set) var xp: Int {
    get { return storage.xp }
    set { storage.xp = newValue }
}
private(set) var level: Int {
    get { return storage.level }
    set { storage.level = newValue }
}

编辑:我在这里将 GameStatus 结构更改为 class。那是因为 GameStatus 缺少值语义。如果有两个 GameStatus 副本,并且您修改其中一个,另一个也可能会更改(因为它们都写入 UserDefaults)。没有值语义的结构是危险的。

重新获得值语义是可能的,值得考虑。例如,您可以返回到您的原始设计,而不是通过 xp 和级别传递到存储,该设计具有从存储加载的显式 "restore" 步骤(我假设 "save" 步骤写入贮存)。那么 GameStatus 将是一个合适的结构。


我还会提取 LevelState,以便您可以更轻松地对其进行测试,并且它会捕获视图之外的业务逻辑。

enum LevelState {
    case showStart
    case showCountdown
    case showFinalCountDown
    init(xp: Int) {
        if xp > 95 {
            self = .showFinalCountDown
        } else if xp > 90 {
            self = .showCountdown
        }
        self = .showStart
    }
}

如果它只被这个视图使用过,嵌套它就可以了。只是不要将其设为私有。您可以测试 LevelView.LevelState 而无需对 LevelView 本身进行任何操作。

然后您可以根据需要更新视图的 GameStatus:

class LevelView: UIView {

    var gameStatus: GameStatus? {
        didSet {
            // Refresh the view with the new status
        }
    }

    var state: LevelState {
        guard let xp = gameStatus?.xp else { return .showStart }
        return LevelState(xp: xp)
    }

    //...configurations depending on the level
}

现在视图本身不需要逻辑测试。您可能会进行基于图像的测试,以确保它在给定不同输入的情况下正确绘制,但这是完全端到端的。所有的逻辑都很简单且可测试。您可以在没有 UIKit 的情况下测试 GameStatus 和 LevelState,只需将 StaticGameStorage 传递给 GameStatus。

解决方案是依赖注入!

您可以创建一个 Persisting 协议和一个外观 class 来与用户默认设置进行交互

protocol Persisting {
  func getObject(key: String) -> Any?
  func persist(value: Any, key: String)
}

final class Persist: Persisting {
  func getObject(key: String) -> Any? {
    return UserDefaults.standard.object(forKey: key)
  }

  func persist(object: Any, key: String) {
    UserDefaults.standard.set(value: object, forKey: key)
  }
}

class MockPersist: Persisting {
  // this is set from the test
  var mockObjectToReturn: Any?
  func getObject(key: String) -> Any? {
    return mockObjectToReturn
  }

  var didCallPersistObject: (Any?, String)
  func persist(object: Any, key: String) {
    didCallPersistObject.0 = object
    didCallPersistObject.1 = key
  }
}

现在在你的结构上,你需要注入一个 Persisting.

类型的变量

测试时,您需要注入 MockPersist 并针对 MockPersist class.

上定义的变量进行断言

希望对您有所帮助