在 Swift 中模拟 singleton/sharedInstance

Mocking singleton/sharedInstance in Swift

我有一个 class,我想使用 XCTest 进行测试,这个 class 看起来像这样:

public class MyClass: NSObject {
    func method() {
         // Do something...
         // ...
         SingletonClass.sharedInstance.callMethod()
    }
}

class 使用单例实现如下:

public class SingletonClass: NSObject {

    // Only accessible using singleton
    static let sharedInstance = SingletonClass()

    private override init() {
        super.init()
    }

    func callMethod() {
        // Do something crazy that shouldn't run under tests
    }
}

现在进行测试。我想测试 method() 实际上做了它应该做的事情,但我不想调用 callMethod() 中的代码(因为它做了一些可怕的 async/network/thread 不应该做的事情t 运行 在 MyClass 的测试下,并且会使测试崩溃)。

所以我基本上想做的是:

SingletonClass = MockSingletonClass: SingletonClass {
    override func callMethod() {
        // Do nothing
    }
let myObject = MyClass()
myObject.method()
// Check if tests passed

这显然是无效的 Swift,但您明白了。我如何才能仅针对此特定测试覆盖 callMethod(),使其无害?

编辑: 我尝试使用依赖注入的形式解决这个问题,但 运行 遇到了大问题。我创建了一个自定义初始化方法,仅用于测试,这样我就可以像这样创建我的对象:

let myObject = MyClass(singleton: MockSingletonClass)

让 MyClass 看起来像这样

public class MyClass: NSObject {
    let singleton: SingletonClass

    init(mockSingleton: SingletonClass){
        self.singleton = mockSingleton
    }

    init() {
        singleton = SingletonClass.sharedInstance
    }

    func method() {
         // Do something...
         // ...
         singleton.callMethod()
    }
}

将测试代码与其余代码混合在一起是我觉得有点不愉快的事情,但没关系。最大的问题是我在我的项目中有两个这样构造的单例,它们都相互引用:

public class FirstSingletonClass: NSObject {
    // Only accessible using singleton
    static let sharedInstance = FirstSingletonClass()

    let secondSingleton: SecondSingletonClass

    init(mockSingleton: SecondSingletonClass){
        self.secondSingleton = mockSingleton
    }

    private override init() {
        secondSingleton = SecondSingletonClass.sharedInstance
        super.init()
    }

    func someMethod(){
        // Use secondSingleton
    }
}

public class SecondSingletonClass: NSObject {
    // Only accessible using singleton
    static let sharedInstance = SecondSingletonClass()

    let firstSingleton: FirstSingletonClass

    init(mockSingleton: FirstSingletonClass){
        self.firstSingleton = mockSingleton
    }

    private override init() {
        firstSingleton = FirstSingletonClass.sharedInstance
        super.init()
    }

    func someOtherMethod(){
        // Use firstSingleton
    }
}

这会在首次使用其中一个单例时造成死锁,因为 init 方法将等待另一个的 init 方法,依此类推...

您的单例遵循 Swift/Objective-C 代码库中非常常见的模式。正如您所见,它也很难测试,并且是编写不可测试代码的一种简单方法。有时单例模式是一种有用的模式,但根据我的经验,该模式的大多数用途实际上并不适合应用程序的需求。

来自Objective-C和Swift的+shared_样式单例class常量单例通常提供两种行为:

  1. 它可能强制只能实例化 class 的单个实例。 (在实践中,这通常不会强制执行,您可以继续 alloc/init 其他实例,而应用程序取决于开发人员遵循通过 class 方法独占访问共享实例的约定.)
  2. 它充当全局,允许访问 class.
  3. 的共享实例

行为 #1 偶尔有用,而行为 #2 只是具有设计模式文凭的全局行为。

我会通过完全删除全局变量来解决您的冲突。始终注入您的依赖项,而不仅仅是为了测试,并考虑当您需要一些东西来协调您正在注入的任何共享资源集时,您的应用程序中暴露的责任。

在整个应用程序中注入依赖项的第一步通常很痛苦; "but I need this instance everywhere!"。使用它作为重新考虑设计的提示,为什么这么多组件访问相同的全局状态以及如何对其建模以提供更好的隔离?


在某些情况下,您需要一些可变共享状态的单个副本,而单例实例可能是最好的实现方式。但是我发现在大多数例子中仍然不成立。开发人员通常在寻找共享状态,但有一些条件:在连接外部显示器之前只有一个屏幕,只有一个用户在他们注销并进入第二个帐户之前只有一个网络请求队列,在您发现需要进行身份验证之前只有一个网络请求队列vs 匿名请求。同样,在执行下一个测试用例之前,您通常需要一个共享实例。

鉴于 "singleton" 似乎很少有人使用可失败的初始化器(或 return 现有共享实例的 obj-c init 方法),开发人员似乎很乐意按照惯例共享此状态,所以我认为没有理由不注入共享对象并编写易于测试的 classes 而不是使用全局变量。

我最终使用代码

解决了这个问题
class SingletonClass: NSObject {

    #if TEST

    // Only used for tests
    static var sharedInstance: SingletonClass!

    // Public init
    override init() {
        super.init()
    }

    #else

    // Only accessible using singleton
    static let sharedInstance = SingletonClass()

    private override init() {
        super.init()
    }

    #endif

    func callMethod() {
        // Do something crazy that shouldn't run under tests
    }

}

这样我就可以在测试期间轻松地模拟我的 class:

private class MockSingleton : SingletonClass {
    override callMethod() {}
}

测试中:

SingletonClass.sharedInstance = MockSingleton()

通过在应用测试目标的构建设置中将 -D TEST 添加到 "Other Swift Flags" 来激活仅测试代码。

我在我的应用程序中遇到了类似的问题,在我的例子中,对这些特定服务使用单例是有意义的,因为它们是外部服务的代理,也是单例。

我最终使用 https://github.com/Swinject/Swinject. It took about a day to implement for about 6 classes which seems like a lot of time to enable this level of unit testability. It did make me think hard about the dependencies between my service classes, and it made me more explicitly indicate these in the class definitions. I used the ObjectScope in Swinject to indicate to the DI framework that they're singletons: https://github.com/Swinject/Swinject/blob/master/Documentation/ObjectScopes.md

实现了一个依赖注入模型

我能够拥有我的单例,并将它们的模拟版本传递给我的单元测试。

关于这种方法的想法:它似乎更容易出错,因为我可能会忘记正确初始化我的依赖项(并且永远不会收到运行时错误)。最后,它不会阻止某人直接实例化我的服务 class 的实例(这是单例的全部要点),因为我的初始化方法必须 public DI 框架将对象实例化到注册表中。

我建议让init不是private的(非常不方便),但是如果你需要模拟数据类型初始化的多次调用,目前没有看到更好的解决方案可以测试对象。