- 原文地址:Avoiding force unwrapping in Swift unit tests
- 原文做者:John
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:RickeyBoy
- 校对者:YinTokey
强制解析(使用 !
)是 Swift 语言中不可或缺的一个重要特色(特别是和 Objective-C 的接口混合使用时)。它回避了一些其余问题,使得 Swift 语言变得更加优秀。好比 处理 Swift 中非可选的可选值类型 这篇文章中,在项目逻辑须要时使用强制解析去处理可选类型,将致使一些离奇的状况和崩溃。前端
因此尽量地避免使用强制解析,将有助于搭建更加稳定的应用,而且在发生错误时提供更好的报错信息。那么若是是编写测试时,状况会怎么样呢?安全地处理可选类型和未知类型须要大量的代码,那么问题就在于咱们是否愿意为编写测试作全部的额外工做。这就是咱们这周将要探讨的问题,让咱们开始深刻研究吧!android
当编写测试代码时,咱们常常明确区分测试代码和产品代码。尽管保持这两部分代码的分离十分重要(咱们不但愿意外地让咱们的模拟测试对象成为 App Store 上架的部分😅),但就代码质量来讲,没有必要进行明显区分。ios
若是你思考一下的话,咱们想要对移交给使用者的代码进行高标准的要求,缘由是什么呢?git
如今若是反过来考虑咱们的测试,咱们想要避免哪些事情呢?github
你可能已经理解我所讲的内容了 😉。express
以前很长的时间,我曾认为测试代码只是一些我快速堆砌的代码,由于有人告诉我必需要编写测试。我不那么在意它们的质量,由于我将它视为一件杂事,并不将它放在首位。然而,一旦我由于编写测试而发现验证本身的代码有多么快,以及对本身有多么自信 —— 我对测试的态度就开始了转变。swift
所如今我相信对于测试代码,和将要移交的产品代码进行同等的高标准要求是很是重要的。由于咱们配套的测试是须要咱们长期使用、拓展和掌握的,咱们理应让这些工做更容易完成。后端
那么这一切与 Swift 中的强制解析有什么关系呢?🤔安全
有时必需要强制解析,很容易编写一个 “go-to solution” 的测试。让咱们来看一个例子,测试 UserService
实现的登录机制是否正常工做:bash
class UserServiceTests: XCTestCase {
func testLoggingIn() {
// 为了登录终端
// 构建一个永远返回成功的模拟对象
let networkManager = NetworkManagerMock()
networkManager.mockResponse(forEndpoint: .login, with: [
"name": "John",
"age": 30
])
// 构建 service 对象以及登陆
let service = UserService(networkManager: networkManager)
service.login(withUsername: "john", password: "password")
// 如今咱们想要基于已登录的用户进行断言,
// 这是可选类型,因此咱们对它进行强制解析
let user = service.loggedInUser!
XCTAssertEqual(user.name, "John")
XCTAssertEqual(user.age, 30)
}
}
复制代码
如你所见,在进行断言以前,咱们强制解析了 service 对象的 loggedInUser
属性。像上面这样的作法并非绝对意义上的错,可是若是这个测试由于一些缘由开始失败,就可能会致使一些问题。
假设某人(记住,“某人”可能就是“将来的你本身”😉)改变了网络部分的代码,致使上述测试开始崩溃。若是这样的事情发生了,错误信息可能只会像下面这样:
Fatal error: Unexpectedly found nil while unwrapping an Optional value
复制代码
尽管用 Xcode 本地运行时这不是个大问题(由于错误会被关联地显示 —— 至少在大多数时候 🙃),但当连续地总体运行整个项目时,它可能问题重重。上述的错误信息可能出如今巨大的“文字墙”中,致使难以看出错误的来源。更严重的是,它会阻止后续的测试被执行(由于测试进程会崩溃),这将致使修复工做进展缓慢而且使人烦躁。
一个潜在的解决上述问题的方式是简单地使用 guard
声明,优雅地解析问题中的可选类型,若是解析失败再调用 XCTFail
便可,就像下面这样:
guard let user = service.loggedInUser else {
XCTFail("Expected a user to be logged in at this point")
return
}
复制代码
尽管上述作法在某些状况下是正确的作法,但事实上我推荐避免使用它 —— 由于它向你的测试中增长了控制流。为了稳定性和可预测性,你一般但愿测试只是简单的遵循 given,when,then 结构,而且增长控制流会使得测试代码难于理解。若是你真的很是倒霉,控制流可能成为误报的起源(对此以后的文章会有更多的相关内容)。
另外一个方法是让可选类型一直保持可选。这在某些使用状况下彻底可用,包括咱们 UserManager
的例子。由于咱们对已经登陆的 user 的 name
和 age
属性使用了断言,若是任意一个属性为 nil
,咱们会自动获得错误提示。同时若是咱们对 user 使用额外的 XCTAssertNotNil
检查,咱们就能获得一个很是完整的诊断信息。
let user = service.loggedInUser
XCTAssertNotNil(user, "Expected a user to be logged in at this point")
XCTAssertEqual(user?.name, "John")
XCTAssertEqual(user?.age, 30)
复制代码
如今若是咱们的测试开始出错了,咱们就能获得以下信息:
XCTAssertNotNil failed - Expected a user to be logged in at this point
XCTAssertEqual failed: ("nil") is not equal to ("Optional("John")")
XCTAssertEqual failed: ("nil") is not equal to ("Optional(30)")
复制代码
这让咱们可以更加容易地知道发生错误的地方,以及该从哪里入手去调试、解决这个错误 🎉。
第三个选择在某些状况下是很是有用的,就是将返回可选类型的 API 替换为 throwing API。Swift 中的 throwing API 的优雅之处在于,须要时它可以很是容易地被当成可选类型使用。因此不少时候选择采用 throwing 方法,不须要牺牲任何的可用性。好比说,假设咱们有一个 EndpointURLFactory
类,被用来在咱们的 app 中生成特定终端的 URL,这显然会返回可选类型:
class EndpointURLFactory {
func makeURL(for endpoint: Endpoint) -> URL? {
...
}
}
复制代码
如今咱们将其转换为采用 throwing API,像这样:
class EndpointURLFactory {
func makeURL(for endpoint: Endpoint) throws -> URL {
...
}
}
复制代码
当咱们仍然想获得一个可选类型的 URL 时,咱们只须要使用 try?
命令去调用它:
let loginEndpoint = try? urlFactory.makeURL(for: .login)
复制代码
就测试而言,上述这种作法的最大好处在于能够在测试中轻松地使用 try
,而且使用 XCTest runner 彻底能够毫无代价地处理无效值。这是不为人知的,但事实上 Swift 测试能够是 throwing 函数,看看这个:
class EndpointURLFactoryTests: XCTestCase {
func testSearchURLContainsQuery() throws {
let factory = EndpointURLFactory()
let query = "Swift"
// 由于咱们的测试函数是 throwing,这里咱们能够简单地采用 'try'
let url = try factory.makeURL(for: .search(query))
XCTAssertTrue(url.absoluteString.contains(query))
}
}
复制代码
没有可选类型,没有强制解析,某些发生错误的时候也能完美地作出诊断 👍。
然而,并非全部返回可选类型的 API 均可以被替换为 throwing。不过在写包含可选类型的测试时,有一个和 throwing API 一样好的方法。
让咱们回到最开始 UserManager
的例子。若是既不对 loggedInUser
进行强制解析,又不把它看做可选类型,那么咱们能够简单地这样作:
let user = try require(service.loggedInUser)
XCTAssertEqual(user.name, "John")
XCTAssertEqual(user.age, 30)
复制代码
这实在是太酷了!😎这样咱们能够摆脱大量的强制解析,同时避免让咱们的测试代码难于编写、难于上手。那么为了达到上述效果咱们应该怎么作呢?这很简单,咱们只须要对 XCTestCase
增长一个拓展,让咱们分析任何可选类型表达式,而且返回非可选的值或者抛出一个错误,像这样:
extension XCTestCase {
// 为了可以输出优雅的错误信息
// 咱们遵循 LocallizedErrow
private struct RequireError<T>: LocalizedError {
let file: StaticString
let line: UInt
// 实现这个属性很是重要
// 不然测试失败时咱们没法在记录中优雅地输出错误信息
var errorDescription: String? {
return "😱 Required value of type \(T.self) was nil at line \(line) in file \(file)."
}
}
// 使用 file 和 line 使得咱们可以自动捕获
// 源代码中出现的相对应的表达式
func require<T>(_ expression: @autoclosure () -> T?,
file: StaticString = #file,
line: UInt = #line) throws -> T {
guard let value = expression() else {
throw RequireError<T>(file: file, line: line)
}
return value
}
}
复制代码
如今有了上述内容,若是咱们 UserManager
登陆测试发生失败,咱们也能获得一个很是优雅的错误信息,告诉咱们错误发生的准确位置。
[UserServiceTests testLoggingIn] : failed: caught error: 😱 Required value of type User was nil at line 97 in file UserServiceTests.swift.
复制代码
你可能意识到这个技巧来源于个人迷你框架 Require, 它对全部可选类型增长了一个 require() 方法,以提升对没法避免的强制解析的诊断效果。
以一样谨慎的态度对待你的应用代码和测试代码,在最开始可能有些不适应,但可让长期维护测试变的更加简单 —— 不管是独立开发仍是团队开发。良好的错误诊断和错误信息是其中特别重要的一部分,使用本文中的一些技巧或许可以让你在将来避免不少奇怪的问题。
我在测试代码中惟一使用强制解析的时候,就是在构建测试案例的属性时。由于这些老是在 setUp
中被建立、tearDown
中被销毁,我并不把他们看成真正的可选类型。正如以往,你一样须要查看你本身的代码,根据你本身的喜爱,来权衡决定。
因此你以为呢?你会采用一些本文中的技巧,仍是你已经用了一些相关的方式?请让我知道,包括你可能有的任何的问题、评价和反馈 —— 能够在下面回复栏直接回复或者在 Twitter @johnsundell 上回复我。
感谢阅读!🚀
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。