在开始进行单元测试时,通常不需要花很长时间才能意识到需要某种形式的模拟。使用模拟时,我们使用各种技术来创建我们想要测试的功能所依赖的对象的“假”版本 - 使得可以在不依赖外部逻辑或网络或数据库调用之类的情况下验证代码的结果。
在“模拟Swift”中,我们看了一下如何在Swift中使用一些常见的模拟技术,虽然模拟很可能对于许多类型的测试仍然是必不可少的,但也有很多情况下避免模拟会导致更简单代码 - 在测试和实际生产代码方面。
本周,让我们来看看其中的一些案例,以及在Swift中编写无模拟单元测试的几种不同方法。
同时小编这里有些书籍和面试资料哦(点击下载)

各地的协议,协议!
重构可测试性代码时,一个常见的抱怨是,它通常会导致我们的代码库变得充满了协议,而旁边的测试并不一定需要存在。例如,假设我们要为使用缓存的类编写测试,以加快重复操作。在这种情况下常见的事情是创建一个CacheProtocol
我们然后使我们的生产类Cache
符合,如下所示:
protocol CacheProtocol {
associatedtype Value
func cache(_ value: Value, key: String)
func value(for key: String) -> Value?
}
extension Cache: CacheProtocol {}
上述方法的好处是我们现在可以使所有依赖的对象Cache
使用CacheProtocol
API,这会导致更多的解耦代码,并让我们在我们的测试中模拟它 - 比如我们在这里编写测试将文章转换为HTML的类:
class ArticleConverterTests: XCTestCase {
func testCachingConvertedHTMLArticle() {
let cache = CacheMock()
let converter = ArticleConverter(cache: cache)
...
}
}
虽然像我们上面那样引入基于协议的抽象,但是在我们的代码库中改进关注点的分离是一种很好的方式- 对于一些(特别是更“重”的)对象,它可能是一个很好的方法 - 它确实增加了一些开销和更多代码需要维护,尤其是在整个代码库中用于许多对象时。它还有将测试代码与生产代码分开的缺点,这有时会导致测试更多地验证模拟代码而不是实际的生产代码。
保持真实
使用模拟的一种替代方法是在我们的测试中实际使用我们的真实对象。虽然并非总是可行,但在很多情况下根本不需要模拟 - 而实例化一个真实的对象可以让我们摆脱只有那些便于测试的协议,并使我们的测试在更现实的条件下运行。
回到ArticleConverterTests
之前的示例,让我们看看如果我们只是简单地使用我们的实际Cache
实现而不是使用模拟,那么测试会是什么样子。我们要做的是,我们将创建一个空白缓存实例,将其注入我们的实例ArticleConverter
,然后使用我们的实际缓存API来验证是否满足了正确的条件 - 如下所示:
class ArticleConverterTests: XCTestCase {
func testCachingConvertedHTMLArticle() {
let cache = Cache<String>()
let converter = ArticleConverter(cache: cache)
let article = Article(title: "Title", text: "Text")
// It's often a good idea to verify our assumptions when
// writing tests, such as checking that the cache doesn't
// actually contain any value *before* we've run our code.
XCTAssertNil(cache.value(for: article.id))
let html = converter.convert(article, to: .html)
XCTAssertFalse(html.isEmpty)
let cachedHTML = cache.value(for: article.id)
XCTAssertEqual(cachedHTML, html)
}
}
虽然有些人可能会说我们现在已经将我们的单元测试变成了集成测试(因为它实际上将我们的Cache
类集成到了ArticleConverter
) - 问题是,如果我们可以简化我们的测试代码,减少对额外协议和模拟的需求 -我们的测试是否是“纯粹的”单元测试真的很重要吗?
暂时的坚持
上面使用真实Cache
实例的例子非常有效 - 但只要我们的缓存不依赖于任何形式的持久性,因为我们否则会因为旧数据仍然存在而导致测试失败。例如,我们可能会意识到我们的缓存也需要将其条目写入磁盘 - 这将使我们之前的测试开始失败。
然而,在我们跳回到嘲弄列车之前,让我们看看我们是否能够提出一个解决方案,让我们仍然可以使用我们的真实Cache
课程,同时还可以消除散乱和测试失败的风险。特别是在持久性方面,解决我们问题的一种方法是打开Cache
配置用于读取和写入磁盘时使用的特定文件路径:
class Cache<Value> {
private let filePath: String
init(filePath: String) {
self.filePath = filePath
}
}
就像使用模拟时一样,这使我们能够控制Cache
测试代码中的一些内部工作,但是以更轻量级的方式 - 不需要引入其他类型。我们现在要做的就是让我们的测试更加可预测的是简单地将我们的缓存指向一个文件路径,我们可以保证它不会包含以前测试运行中的任何旧状态。
为了能够以一种简单的方式做到这一点,让我们创建一个函数,使我们能够获得一个临时文件路径,我们确保它还没有包含任何数据。我们将使用NSTemporaryDirectory
访问适合存储临时数据的目录,并使用#function
编译器指令自动传入正在使用我们函数的测试名称 - 如下所示:
func makeTemporaryFilePathForTest(
named testName: StaticString = #function
) -> String {
let path = NSTemporaryDirectory() + "\(testName)"
try? FileManager.default.removeItem(atPath: path)
return path
}
我们现在仍然可以使用我们的真实Cache
类,但通过调用获得特定的文件路径makeTemporaryFilePathForTest
,这将使我们的测试继续以可预测的方式执行:
class ArticleConverterTests: XCTestCase {
func testCachingConvertedHTMLArticle() {
let filePath = makeTemporaryFilePathForTest()
let cache = Cache<String>(filePath: filePath)
...
}
}
就像我们使用上面的临时文件路径一样,我们可以沿着相同的路线做一些事情,以避免为许多其他类型的测试引入模拟。例如,我们可以UserDefaults
通过使用特定的方法创建一个受我们控制的实例suiteName
,我们可以使用我们的测试包来处理Bundle
API的代码,我们可以使用特定的URLSession
网络实例。
功能行为
有时我们希望测试的对象依赖于某种形式的异步行为,比如执行网络请求。这是另一个模拟非常受欢迎的领域 - 我们将简单地创建一个模拟版本的网络类,我们可以使用它来使我们的异步网络代码同步,并使我们的测试更快,更可预测。
同样,对于更复杂的网络代码,这是一个很好的解决方案 - 但是如果我们只需要执行单个请求,我们真的需要为此引入模拟吗?
让我们看一下另一个例子,我们正在构建一个SettingsManager
允许用户在我们的应用程序中启用和禁用各种设置的示例。每次更改设置时,我们的管理器都会执行网络调用,使用静态Networking
API 将该设置传播到我们的服务器- 如下所示:
class SettingsManager {
private var settings: [Setting : Bool]
init(settings: [Setting : Bool]) {
self.settings = settings
}
func enable(_ setting: Setting) {
settings[setting] = true
Networking.request(.updateSetting(setting, isOn: true))
}
func disable(_ setting: Setting) {
settings[setting] = false
Networking.request(.updateSetting(setting, isOn: false))
}
}
以上是一个非常常见的棘手情况的例子 - 特别是在使用单元测试改进旧的未经测试的代码时。问题是双重的 - 我们都需要找到一些方法来替换我们测试中的实际网络,我们还要处理一个静态API,这通常很难模拟(因为我们不能简单地注入一个模拟实例) )。
在这种情况下,利用Swift的一流功能可以提供一个非常简洁的解决方案 - 再次无需任何其他协议或模拟实例。使用函数依赖注入,我们可以创建一个非常简单的抽象,它将隐藏所有网络代码从我们SettingsManager
的Syncing
函数后面,我们在初始化器中注入,然后从我们enable
和disable
方法调用- 像这样:
class SettingsManager {
// We use typealiases to avoid having to repeat the same
// signatures in multiple places:
typealias Settings = [Setting : Bool]
typealias Syncing = (Setting, Bool) -> Void
private var settings: Settings
private let syncing: Syncing
init(settings: [Setting : Bool],
syncing: @escaping Syncing) {
self.settings = settings
self.syncing = syncing
}
func enable(_ setting: Setting) {
settings[setting] = true
syncing(setting, true)
}
func disable(_ setting: Setting) {
settings[setting] = false
syncing(setting, false)
}
}
上述方法的优点在于我们现在SettingsManager
完全没有意识到任何网络 - 它只是调用syncing
它希望同步的设置,而其他一些代码则负责其余部分,这在关注点分离方面是完美的。
以这种方式使用函数不仅在内部给我们一个非常简单的语法,它还使得创建一个SettingsManager
简单的实例。我们不是必须处理各种对象的多个实例,而是简单地定义一个闭包,该闭包又调用我们的网络API(如果我们愿意,它可以保持静态),然后我们将其注入到我们的经理中:
func makeSettingsManager() -> SettingsManager {
// We first capture our 'updateSetting' method as a closure
// in order to be able to pass $0 (the setting) and $1 (the
// bool flag) directly into it.
let makeEndpoint = Endpoint.updateSetting
let syncing = { Networking.request(makeEndpoint($0, $1)) }
return SettingsManager(
settings: user.settings,
syncing: syncing
)
}
由于我们SettingsManager
不再直接依赖任何网络代码,因此我们现在可以轻松地为其编写测试。我们所要做的就是向它传递一个同步闭包,它只是更新一个本地settings
字典,然后我们可以用它进行验证:
class SettingsManagerTests: XCTestCase {
func testSyncingAfterEnablingSetting() {
var settings = [Setting : Bool]()
let syncing = { settings[$0] = $1 }
let manager = SettingsManager(
settings: [:],
syncing: syncing
)
manager.enable(.profileIsPublic)
XCTAssertEqual(settings, [.profileIsPublic : true])
}
}
通过这种方式使用依赖注入函数,我们不仅可以(甚至很容易)测试我们SettingsManager
,而且我们也简化了它的内部实现 - 大赢!🎉
结论
模拟仍然是在Swift编写测试的重要部分,但它不是一个银弹。有时使用替代技术 - 比如使用函数而不是协议并创建对象的实际实例 - 可以使代码更简单,更易于使用。像往常一样,在我们的“虚拟工具带”中使用更多工具可以让我们为任何给定的情况选择最合适的技术,无论是否涉及创建协议和模拟对象。
你怎么看?您目前是否非常依赖于模拟,并且您能够使用本文中的一种技术替换其中一些 - 或者您是否有其他首选方法来编写无模拟单元测试?请通过加我们的交流群 点击此处进交流群 ,来一起交流或者发布您的问题,意见或反馈。
网友评论