【问题标题】:Mocking singleton/sharedInstance in Swift在 Swift 中模拟单例/共享实例
【发布时间】:2016-05-07 18:59:11
【问题描述】:

我有一个要使用 XCTest 测试的类,这个类看起来像这样:

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

这个类使用了一个这样实现的单例:

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() 中的代码(因为它做了一些不应该在测试下运行的可怕的异步/网络/线程的东西MyClass,并且会使测试崩溃)。

所以我基本上想做的是:

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

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

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

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 mocking singleton xctest


    【解决方案1】:

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

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

    1. 它可能会强制只实例化一个类的单个实例。 (实际上,这通常不会强制执行,您可以继续alloc/init 其他实例,而应用程序依赖于开发人员遵循通过类方法专门访问共享实例的约定。)
    2. 它充当全局,允许访问类的共享实例。

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

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

    在整个应用程序中注入依赖项的第一步通常很痛苦; “但我到处都需要这个实例!​​”。使用它作为重新考虑设计的提示,为什么这么多组件访问相同的全局状态以及如何对其进行建模以提供更好的隔离?


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

    鉴于“单例”似乎很少使用可失败的初始化程序(或返回现有共享实例的 obj-c 初始化方法),开发人员似乎很乐意按照惯例共享此状态,因此我认为没有理由不注入共享对象并编写易于测试的类,而不是使用全局变量。

    【讨论】:

      【解决方案2】:

      我最终使用代码解决了这个问题

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

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

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

      在测试中:

      SingletonClass.sharedInstance = MockSingleton()
      

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

      【讨论】:

      • 让测试泄漏到生产代码中的糟糕解决方案
      【解决方案3】:

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

      我最终使用https://github.com/Swinject/Swinject 实现了一个依赖注入模型。大约花了一天时间来实现大约 6 个类,这似乎需要很多时间来实现这种级别的单元可测试性。它确实让我认真思考了我的服务类之间的依赖关系,它让我在类定义中更明确地指出了这些。我在 Swinject 中使用 ObjectScope 向 DI 框架表明它们是单例:https://github.com/Swinject/Swinject/blob/master/Documentation/ObjectScopes.md

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

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

      【讨论】:

        【解决方案4】:

        我建议将 init 设为非私有(非常不方便),但如果您需要模拟数据类型初始化的多次调用,目前还没有更好的解决方案可以测试对象。

        【讨论】:

          猜你喜欢
          • 2015-04-07
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2012-10-31
          • 1970-01-01
          • 1970-01-01
          • 2020-12-05
          • 2019-09-19
          相关资源
          最近更新 更多