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

时间:2019-03-08 17:04:29

标签: swift unit-testing xctest xctestcase

很明显,在UnitTest中,您

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

但是,如果您有一个全局结构,例如具有私人设置器且无法修改的游戏XP和游戏等级。应用启动时,我会从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
}

2 个答案:

答案 0 :(得分:2)

首先,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和level就可以传递到存储中。无需特殊的“立即加载存储”步骤。

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更改为类的结构。这是因为GameStatus缺少值语义。如果有两个GameStatus副本,并且您修改了其中一个,则另一个可能也会更改(因为它们都写入UserDefaults)。没有值语义的结构很危险。

可以重新获得值的语义,这值得考虑。例如,您可以回到原始设计,而不是通过xp和关卡传递到存储,该设计具有从存储加载的显式“恢复”步骤(并且我假设写入存储的“保存”步骤)。那么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
}

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

答案 1 :(得分:1)

解决方案是依赖注入!

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

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的var。

在测试时,您需要注入MockPersist并针对MockPersist类中定义的var进行断言。

希望这会有所帮助