如何使用全局结构进行单元测试?
How to Unit-Test with global structs?
我很清楚在 UnitTest 中你
- 生成一个输入属性
- 将此 属性 传递给您要测试的方法
- 将结果与您的预期结果进行比较
但是,如果您有一个全局结构,例如游戏经验值和游戏关卡有私人设置器,不能修改。当应用程序启动时,我会自动从 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.
上定义的变量进行断言
希望对您有所帮助
我很清楚在 UnitTest 中你
- 生成一个输入属性
- 将此 属性 传递给您要测试的方法
- 将结果与您的预期结果进行比较
但是,如果您有一个全局结构,例如游戏经验值和游戏关卡有私人设置器,不能修改。当应用程序启动时,我会自动从 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.
希望对您有所帮助