很多时候,使代码易于单元测试往往是与改进代码的关注点分离、状态管理和整体架构同步进行的。一般来说,我们的代码抽象化和组织化程度越高,就越容易以自动化的方式进行测试。
然而,为了使代码更易测试,我们经常发现自己引入了大量的新协议和其他类型的抽象,并最终使我们的代码在这个过程中变得更加复杂--尤其是在测试依赖于某种网络形式的异步代码时。
但是,真的必须要这样吗?如果我们真的可以使我们的代码完全可测试,而不需要我们引入任何新的协议、嘲弄类型或复杂的抽象呢?让我们来探讨一下我们如何利用Swift的新async/await 能力来实现这一目标。
注入式网络
假设我们正在开发一个应用程序,其中包括以下ProductViewModel ,它使用了非常常见的模式,即通过初始化器注入其URLSession (它将用于执行网络调用):
class ProductViewModel {
var title: String { product.name }
var detailText: String { product.description }
var price: Price { product.price(in: localUser.currency) }
...
private var product: Product
private let localUser: User
private let urlSession: URLSession
init(product: Product, localUser: User, urlSession: URLSession = .shared) {
self.product = product
self.localUser = localUser
self.urlSession = urlSession
}
func reload() async throws {
let url = URL.forLoadingProduct(withID: product.id)
let (data, _) = try await urlSession.data(from: url)
let decoder = JSONDecoder()
product = try decoder.decode(Product.self, from: data)
}
}
现在,上面的代码真的没有什么问题。它可以工作,而且它使用了依赖注入来避免直接访问URLSession.shared (这在测试和整体架构方面已经有了巨大的好处),尽管为了方便,它确实默认使用了那个shared 实例。
然而,可以肯定的是,在视图模型和视图控制器等类型中内联原始网络调用是应该避免的--因为这将在我们的项目中创造一个更好的关注点分离,并让我们在需要在其他地方执行类似请求时重复使用这些网络代码。
所以,为了继续迭代上面的例子,让我们把我们的视图模型的产品加载代码提取到一个专门的ProductLoader 类型中。
class ProductLoader {
private let urlSession: URLSession
init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
func loadProduct(withID id: Product.ID) async throws -> Product {
let url = URL.forLoadingProduct(withID: id)
let (data, _) = try await urlSession.data(from: url)
let decoder = JSONDecoder()
return try decoder.decode(Product.self, from: data)
}
}
如果我们让我们的视图模型使用这个新的ProductLoader ,而不是直接与URLSession 交互,那么我们可以大大简化它的实现--因为它现在可以在被要求重新加载其底层数据模型时简单地调用loadProduct 。
class ProductViewModel {
...
private var product: Product
private let localUser: User
private let loader: ProductLoader
init(product: Product, localUser: User, loader: ProductLoader) {
self.product = product
self.localUser = localUser
self.loader = loader
}
func reload() async throws {
product = try await loader.loadProduct(withID: product.id)
}
}
所以这已经是相当大的进步了。但如果我们现在想实现一些单元测试,以确保我们的视图模型的行为符合我们的期望呢?要做到这一点,我们需要以某种方式模拟我们的应用程序的网络,因为我们肯定不希望在我们的单元测试中执行任何真正的网络调用(因为这可能会增加延迟和不稳定,并要求我们在我们的代码库工作时始终在线)。
基于协议的嘲弄
设置这种嘲弄的一种方法是创建一个基于协议的Networking 抽象,这基本上只需要我们在该协议中复制URLSession.data 方法的签名,然后通过扩展使URLSession 符合我们的新协议--像这样:
protocol Networking {
func data(
from url: URL,
delegate: URLSessionTaskDelegate?
) async throws -> (Data, URLResponse)
}
extension Networking {
// If we want to avoid having to always pass 'delegate: nil'
// at call sites where we're not interested in using a delegate,
// we also have to add the following convenience API (which
// URLSession itself provides when using it directly):
func data(from url: URL) async throws -> (Data, URLResponse) {
try await data(from: url, delegate: nil)
}
}
extension URLSession: Networking {}
有了以上这些,我们现在可以让我们的ProductLoader 接受任何符合我们新的Networking 协议的对象,而不是总是使用一个具体的URLSession 实例(为了方便,我们仍然默认为URLSession.shared )。
class ProductLoader {
private let networking: Networking
init(networking: Networking = URLSession.shared) {
self.networking = networking
}
func loadProduct(withID id: Product.ID) async throws -> Product {
let url = URL.forLoadingProduct(withID: id)
let (data, _) = try await networking.data(from: url)
let decoder = JSONDecoder()
return try decoder.decode(Product.self, from: data)
}
}
在完成了所有的准备工作后,我们现在终于可以开始编写我们的测试了。为了做到这一点,我们将首先创建一个我们的Networking 协议的模拟实现,然后我们将设置一个ProductLoader 和ProductViewModel ,在执行所有的网络调用时使用该模拟实现--这反过来使我们能够像这样编写我们的测试:
class NetworkingMock: Networking {
var result = Result<Data, Error>.success(Data())
func data(
from url: URL,
delegate: URLSessionTaskDelegate?
) async throws -> (Data, URLResponse) {
try (result.get(), URLResponse())
}
}
class ProductViewModelTests: XCTestCase {
private var product: Product!
private var networking: NetworkingMock!
private var viewModel: ProductViewModel!
override func setUp() {
super.setUp()
product = .stub()
networking = NetworkingMock()
viewModel = ProductViewModel(
product: product,
localUser: .stub(),
loader: ProductLoader(networking: networking)
)
}
func testReloadingProductUpdatesTitle() async throws {
product.name = "Reloaded product"
networking.result = try .success(JSONEncoder().encode(product))
XCTAssertNotEqual(viewModel.title, product.name)
try await viewModel.reload()
XCTAssertEqual(viewModel.title, product.name)
}
...
}
要了解更多关于上面调用的.stub() 方法,以便生成我们数据模型的存根版本,请查看"在Swift中定义测试数据"。
好了!我们现在已经成功地重构了我们所有与ProductViewModel 有关的代码,使之成为完全可测试的,并且我们已经开始用单元测试来覆盖它。非常好。
但是,如果我们仔细看看上面的测试案例,我们可以看到,我们的ProductLoader 并没有真正参与到我们的测试代码中。这是因为在这种情况下,我们真正感兴趣的是模拟我们的实际网络代码,因为那是我们代码的一部分,在测试环境下运行会有问题。
现在,可以肯定的说,我们也应该为ProductLoader 添加一个额外的协议和嘲弄层--这可以让我们直接嘲弄它,而不是用一个被嘲弄的网络实例来实现它的真实、实际的实现。你甚至可以说,上面的单元测试实际上根本不是一个单元测试--而是一个集成测试,因为它在进行验证时集成了多个单元(我们的视图模型、产品加载器和网络)。
然而,如果我们采取单元测试的路线,并引入另一个协议和嘲弄类型,那么我们可能很快就会陷入一个滑坡,我们代码库中的每一个对象都有一个相关的协议和嘲弄类型,这将导致大量的代码重复和增加复杂性(即使使用代码生成工具来自动生成这些类型)。
但是,也许有一种方法可以让我们拥有我们的蛋糕,并把它吃掉?让我们来看看,我们是否可以让上面的测试案例作为一个单元来验证我们的ProductViewModel ,同时在这个过程中摆脱那些模拟和测试专用协议。
增加一点函数式编程的内容
如果我们不再用类和协议等面向对象的结构来考虑我们的产品加载代码,而是从更多的功能角度来看待它,那么我们实际上可以用下面的函数签名来模拟我们的视图模型的加载代码:
typealias Loading<T> = () async throws -> T
也就是说,一个函数以异步方式加载某种形式的值,并返回该值,或者抛出一个错误。
接下来,让我们再一次改变我们的ProductViewModel ,现在接受一些符合上述签名的函数(使用我们的Product 模型专门化),而不是直接接受一个ProductLoader 实例:
class ProductViewModel {
...
private var product: Product
private let localUser: User
private let reloading: Loading<Product>
init(product: Product,
localUser: User,
reloading: @escaping Loading<Product>) {
self.product = product
self.localUser = localUser
self.reloading = reloading
}
func reload() async throws {
product = try await reloading()
}
}
上述模式的一个好处是,它仍然可以让我们像以前一样继续使用现有的Networking 和ProductLoader 代码--我们所要做的就是在reloading 函数/外壳中调用这些代码,我们在创建ProductViewModel 时将其传入:
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking
) -> ProductViewModel {
let loader = ProductLoader(networking: networking)
return ProductViewModel(
product: product,
localUser: localUser,
reloading: {
try await loader.loadProduct(withID: product.id)
}
)
}
如果你读Sundell写的Swift已经有一段时间了,那么你可能会从2019年的"Swift中的功能网络 "文章中认出上述模式,该文章使用Futures和Promises来实现类似的结果。
但这里是事情变得真正有趣的地方。现在,当单元测试我们的ProductViewModel ,我们不再需要担心模拟我们的网络,甚至创建一个ProductLoader 实例--我们所要做的就是注入一个内联闭包,返回一个特定的Product 值,然后只要我们想以任何方式改变我们的重载响应,我们就可以对其进行突变。
class ProductViewModelTests: XCTestCase {
private var product: Product!
private var viewModel: ProductViewModel!
override func setUp() {
super.setUp()
product = .stub()
viewModel = ProductViewModel(
product: product,
localUser: .stub(),
reloading: { [unowned self] in self.product }
)
}
func testReloadingProductUpdatesTitle() async throws {
product.name = "Reloaded product"
XCTAssertNotEqual(viewModel.title, product.name)
try await viewModel.reload()
XCTAssertEqual(viewModel.title, product.name)
}
...
}
请注意,在我们的整个测试案例中,不再有任何协议或嘲弄类型。由于我们现在已经将我们的ProductViewModel 与我们的网络代码完全分离,我们可以在完全隔离的情况下对该类进行单元测试--因为,就它而言,它只是访问一个闭包,从某个地方加载一个Product 值。
扩大规模
但现在最大的问题是--如果我们需要在一个给定的类型中执行多种类型的加载操作,这种模式如何扩展?为了探索这个问题,让我们从引入第二种异步函数签名开始,这将让我们使用一个给定的值执行一个动作。
typealias AsyncAction<T> = (T) async throws -> Void
然后,假设我们想扩展我们的ProductViewModel ,支持将一个给定的产品标记为最喜欢的产品,并能够将该产品添加到用户定义的列表中。为了实现这一点,我们可以将这两个新的功能作为单独的闭包注入--像这样:
class ProductViewModel {
...
private let reloading: Loading<Product>
private let favoriteToggling: Loading<Product>
private let listAdding: AsyncAction<List.ID>
init(product: Product,
localUser: User,
reloading: @escaping Loading<Product>,
favoriteToggling: @escaping Loading<Product>,
listAdding: @escaping AsyncAction<List.ID>) {
self.product = product
self.localUser = localUser
self.reloading = reloading
self.favoriteToggling = favoriteToggling
self.listAdding = listAdding
}
func reload() async throws {
product = try await reloading()
}
func toggleProductFavoriteStatus() async throws {
product = try await favoriteToggling()
}
func addProductToList(withID listID: List.ID) async throws {
try await listAdding(listID)
}
}
上面的方法仍然可以正常工作,但我们的实现可以说开始变得有点混乱,因为我们现在不得不在初始化视图模型时处理多个闭包。
因此,让我们从"在Swift中提取视图控制器动作 "中获得一些灵感,将上述三个闭包组合成一个Actions 结构,这将在实现和初始化我们的ProductViewModel ,给我们增加一些结构(不是双关语)。
class ProductViewModel {
...
private let actions: Actions
init(product: Product, localUser: User, actions: Actions) {
self.product = product
self.localUser = localUser
self.actions = actions
}
func reload() async throws {
product = try await actions.reload()
}
func toggleProductFavoriteStatus() async throws {
product = try await actions.toggleFavorite()
}
func addProductToList(withID listID: List.ID) async throws {
try await actions.addToList(listID)
}
}
extension ProductViewModel {
struct Actions {
var reload: Loading<Product>
var toggleFavorite: Loading<Product>
var addToList: AsyncAction<List.ID>
}
}
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking,
listManager: ListManager
) -> ProductViewModel {
let loader = ProductLoader(networking: networking)
return ProductViewModel(
product: product,
localUser: localUser,
actions: ProductViewModel.Actions(
reload: {
try await loader.loadProduct(withID: product.id)
},
toggleFavorite: {
try await loader.toggleFavoriteStatusForProduct(
withID: product.id
)
},
addToList: { listID in
try await listManager.addProduct(
withID: product.id,
toListWithID: listID
)
}
)
)
}
有了上述变化,我们仍然可以在测试中使用简单的闭包来模拟上述三个动作,同时也可以方便地管理这些动作,尤其是在我们将来不断添加新动作的时候。当然,上述模式对于有10个、15个、20个动作的类型来说,可能不会有那么好的扩展性--但在这一点上,可能也值得问一下,也许这个类型一开始就有太多的责任。
然而,对上述模式的一个公平的批评是,它确实最终将我们的ProductViewModel 的一些内部实现细节推给了创建实例的调用站点。例如,我们的makeProductViewModel 函数现在必须确切地知道它应该在我们的视图模型的每个Action 闭包中放置什么逻辑。
解决这个问题的一个方法是提供这些闭包的默认实现,使用我们的生产代码最好使用的底层对象--这可以通过一个扩展来完成,这个扩展可以和我们的ProductViewModel 本身放在同一个文件中:
extension ProductViewModel.Actions {
init(productID: Product.ID,
loader: ProductLoader,
listManager: ListManager) {
reload = {
try await loader.loadProduct(withID: productID)
}
toggleFavorite = {
try await loader.toggleFavoriteStatusForProduct(
withID: productID
)
}
addToList = {
try await listManager.addProduct(
withID: productID,
toListWithID: $0
)
}
}
}
有了这最后的调整,我们的makeProductViewModel ,现在可以简单地注入我们的视图模型的依赖关系,或多或少地与使用我们早期基于协议的设置时的情况完全一样。
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking,
listManager: ListManager
) -> ProductViewModel {
ProductViewModel(
product: product,
localUser: localUser,
actions: ProductViewModel.Actions(
productID: product.id,
loader: ProductLoader(networking: networking),
listManager: listManager
)
)
}
有了这种方法,我们可以说在能够通过一套非常轻量级的抽象来对我们的视图模型进行单元测试,同时也不会将任何实现细节泄露给我们生产代码中初始化该视图模型的任何调用站点之间取得了相当好的平衡。
总结
虽然可能没有完美的依赖注入设置,但通过试验不同的技术,我们通常可以达到一个架构,在我们的代码库的组织方式、测试需求和开发人员的个人偏好之间取得一个完美的平衡。
我希望你觉得这篇文章有趣和有用,虽然我不是说任何人都应该用上述那种功能设置来取代他们所有的协议,但我认为这是一种至少值得探索的方法--尤其是现在我们有了async/await 的力量。
谢谢你的阅读!