使用Swift协议来提升代码的可测试性

本文翻译自《Improving code testability with Swift protocols》

lego_bricks.png

作为开发者,面临的最大挑战之一就是实现高可测试性的代码。这些测试是非常有用的,它不仅保证已完成的代码正常工作,并且确保添加新功能时不会影响旧功能。当你在团队中工作时,会有许多人修改同一个项目,因此确保代码的完整性就更加重要了。

有许多种测试方法,它们应该是清晰、明确、简单的。那么,为什么许多开发者不写测试呢?主要的 excuse 原因 是时间不够。我认为,其中很大的因素是由于我们的代码 在不同层、类、外部依赖框架之间的关系过于耦合。

我想证明创建一个框架的抽象层或者解耦类并不是一件困难的事。

本文代码 GitHub

场景

假设我们需要开发一个获取用户定位的应用,我们需要使用到 CoreLocation

ViewController 中的代码可能像下面这样:

import UIKit
import CoreLocation

class ViewController: UIViewController {
    
    var locationManager: CLLocationManager
    var userLocation: CLLocation?
    
    init(locationProvider: CLLocationManager = CLLocationManager()) {
        self.locationManager = locationProvider
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        locationManager.delegate = self
    }

    func requestUserLocation() {
        if case .authorizedWhenInUse = CLLocationManager.authorizationStatus() {
            locationManager.startUpdatingLocation()
        } else {
            locationManager.requestWhenInUseAuthorization()
        }
    }
}

extension ViewController: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if case .authorizedWhenInUse = status {
            manager.startUpdatingLocation()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        userLocation = locations.last
        manager.stopUpdatingLocation()
    }
}

ViewController 有一个属性 locationManager,它是 CLLocationManager 的实例,用来请求用户的位置或权限。ViewController 遵守 CLLocationManagerDelegate 协议,接收 locationManager 输出的信息。

在这里,我们可以看到 ViewControllerCoreLocation 耦合在一起,存在职责分离不清晰等问题。

无论如何,让我们试着为 ViewController 编写测试代码,这是一个例子:

class ViewControllerTests: XCTestCase {
    
    var sut: ViewController!

    override func setUp() {
        super.setUp()
        sut = ViewController(locationProvider: CLLocationManager())
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }
    
    func testRequestUserLocation() {
        sut.requestUserLocation()
        XCTAssertNotNil(sut.userLocation)
    }
}

我们能够看到 sut (System Under Test) 和一个可能的测试用例。我们请求用户的位置,并将它存到变量 userLocation 中。

问题开始浮现了,虽然 CLLocationManager 负责请求用户位置,但它不是同步处理并返回结果的。所以当我们检查存储的位置属性时,userLocation 仍然为空。另外,我们也可能还未得到获取定位的权限,在这种场景下,userLocation 也将为空。

现在,我们有一些可能的解决方案:

  • 测试 ViewController 但不测试与位置相关的任何内容。
  • 创建 CLLocationManager 的子类并模拟方法,或者尝试正确的执行它并将 CLLocationManager 与我们的类解耦。

我选择后者。

面向协议编程来解决

“At the heart of Swift’s design are two incredibly powerful ideas: protocol-oriented programming and first class value semantics” - Apple

面向协议编程 对开发人员来说是非常强大的工具,而Swift无疑是一种面向协议的语言。所以我的建议是使用协议解决这些依赖关系。

首先,要抽象 CLLocation,先定义一个带有我们需要的变量和函数的协议。

typealias Coordinate = CLLocationCoordinate2D

protocol UserLocation {
    var coordinate: Coordinate { get }
}

extension CLLocation: UserLocation {}

现在,我们可以得到一个不需要 CoreLocationlocation。所以如果我们分析 ViewController,会发现其实也并不是真的需要 CLLocationManager,我们只是需要一个能够提供用户位置的提供者。因此,让我们来创建一个包含我们所需内容的协议,只要是遵守该协议的东西都能作为我们的位置提供者。

enum UserLocationError: Error {
    case canNotBeLocated
}

typealias UserLocationCompletionBlock = (Result<UserLocation, UserLocationError>) -> Void

protocol UserLocationProvider {
    func findUserLocation(then: @escaping UserLocationCompletionBlock)
}

UserLocationProvider 协议,它声明了我们所需的请求用户位置的方法,并将结果通过回调函数返回。

我们准备创建一个 UserLocationService,它将遵守提供用户位置的协议。通过这种方式,我们解决了 CoreLocationViewController 之间的依赖。但是...等等,UserLocationService 仍需要通过 CLLocationManager 来请求用户位置......。问题似乎还未解决😅。

再次使用协议来解决这个问题,只需创建一个新的协议来指定什么是位置提供者。

protocol LocationProvider {
    var isUserAuthorized: Bool { get }
    func requestWhenInUseAuthorization()
    func startUpdatingLocation()
}

extension CLLocationManager: LocationProvider {
    var isUserAuthorized: Bool {
        return CLLocationManager.authorizationStatus() == .authorizedWhenInUse
    }
}

扩展 CLLocationManager,让它遵守我们的新协议。

现在,我们准备好创建 UserLocationService 🎉了,它看起来像下面这样:

class UserLocationService: NSObject, UserLocationProvider {
    
    private var provider: LocationProvider
    private var locationCompletionBlock: UserLocationCompletionBlock?
    
    init(provider: LocationProvider) {
        self.provider = provider
        super.init()
    }
    
    func findUserLocation(then: @escaping UserLocationCompletionBlock) {
        self.locationCompletionBlock = then
        if provider.isUserAuthorized {
            provider.startUpdatingLocation()
        } else {
            provider.requestWhenInUseAuthorization()
        }
    }
}

extension UserLocationService: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if status == .authorizedWhenInUse {
            provider.startUpdatingLocation()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            locationCompletionBlock?(.success(location))
        } else {
            locationCompletionBlock?(.failure(.canNotBeLocated))
        }
    }
}

UserLocationService 有自己的位置提供者,但它并关心也不知道这个提供者具体是谁,因为它只需要请求位置时获取到用户所在的位置就够了,其余的不是他的责任。

因为我们将使用 CoreLocation,所以让 UserLocationService 遵守 CLLocationManager 协议。但是我们在测试代码中并不会用到这个协议。

我们可以在 UserLocationProvider 协议中添加任何类型的代理,但对于我们的例子来说,我认为它会显得太多余了。

在开始测试之前,看看用 UserLocationProvider 代替 CLLocationManager 之后,ViewController 的新面貌。

class ViewController: UIViewController {
    
    var locationProvider: UserLocationProvider
    var userLocation: UserLocation?
    
    init(locationProvider: UserLocationProvider) {
        self.locationProvider = locationProvider
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func requestUserLocation() {
        locationProvider.findUserLocation { [weak self] (result) in
            switch result {
            case .success(let location):
                self?.userLocation = location
            case .failure:
                print("User can not be located 😔 ")
            }
        }
    }
}

可以看到,ViewController 拥有更少的代码,更少的职责,更易于测试了。

Tests

让我们开始测试,首先我们需要创建一些模拟类用来测试 ViewController

struct UserLocationMock: UserLocation {
    var coordinate: Coordinate {
        return Coordinate(latitude: 51.509865, longitude: -0.118092)
    }
}

class UserLocationProviderMock: UserLocationProvider {
    
    var locationResult: Result<UserLocation, UserLocationError>?
    
    func findUserLocation(then: @escaping UserLocationCompletionBlock) {
        if let result = locationResult {
            then(result)
        }
    }
}

使用这些模拟类,可以注入任何我们需要的结果,我们将模拟 UserLocationProvider 的工作方式。因此,我们将重点放在我们的真实目标 ViewController 上。

class ViewControllerTests: XCTestCase {
    
    var sut: ViewController!
    var locationProvider: UserLocationProviderMock!

    override func setUp() {
        super.setUp()
        locationProvider = UserLocationProviderMock()
        sut = ViewController(locationProvider: locationProvider)
    }

    override func tearDown() {
        sut = nil
        locationProvider = nil
        super.tearDown()
    }

    func testRequestUserLocation_NotAuthorized_ShouldFail() {
        // Given
        locationProvider.locationResult = .failure(.canNotBeLocated)
        
        // When
        sut.requestUserLocation()
        
        // Then
        XCTAssertNil(sut.userLocation)
    }
    
    func testRequestUserLocation_Authorized_ShouldReturnUserLocation() {
        // Given
        locationProvider.locationResult = .success(UserLocationMock())
        
        // When
        sut.requestUserLocation()
        
        // Then
        XCTAssertNotNil(sut.userLocation)
    }
}

我们创建了两个测试用例,一个用例检查是否有获取用户位置的权限,该位置提供者没有提供位置。另一个是相反的用例,如果有权限,将获取到用户的位置。就像你看到的那样,测试通过了!✅ 💪

除了 ViewController 我们还创建了一个额外的类 UserLocationService,因此我们的测试也应该覆盖它。

LocationProvider 需要被mock,因为它不是我们测试的目标对象。

class LocationProviderMock: LocationProvider {
    
    var isRequestWhenInUseAuthorizationCalled = false
    var isStartUpdatingLocationCalled = false

    var isUserAuthorized: Bool = false
    
    func requestWhenInUseAuthorization() {
        isRequestWhenInUseAuthorizationCalled = true
    }
    
    func startUpdatingLocation() {
        isStartUpdatingLocationCalled = true
    }
}

可以创建许多测试,其中之一是向提供者验证我们是否有权限,如果没有,则请求授权;如果有,就请求位置。

class UserLocationServiceTests: XCTestCase {
    
    var sut: UserLocationService!
    var locationProvider: LocationProviderMock!

    override func setUp() {
        super.setUp()
        locationProvider = LocationProviderMock()
        sut = UserLocationService(provider: locationProvider)
    }

    override func tearDown() {
        sut = nil
        locationProvider = nil
        super.tearDown()
    }
    
    func testRequestUserLocation_NotAuthorized_ShouldRequestAuthorization() {
        // Given
        locationProvider.isUserAuthorized = false
        
        // When
        sut.findUserLocation { _ in }
        
        // Then
        XCTAssertTrue(locationProvider.isRequestWhenInUseAuthorizationCalled)
    }
    
    func testRequestUserLocation_Authorized_ShouldNotRequestAuthorization() {
        // Given
        locationProvider.isUserAuthorized = true
        
        // When
        sut.findUserLocation { _ in }
        
        // Then
        XCTAssertFalse(locationProvider.isRequestWhenInUseAuthorizationCalled)
    }
}

总结

有许多种解耦代码的方式,而本文只是其中之一。但我认为本文可能是一个很好的例子,表明测试不是一项艰巨的任务。

如果你还记得文章顶部的图片,你可以看到乐高积木,我认为它们很好的解释了什么是解耦和抽象你的组件。最后,它被定义为一种特定的连接方式,但颜色并不重要。

创建mock对象可能是其中最懒得任务,不过已经有一些库和工具来辅助做这件事,例如:Sourcery。我的同事 Hugo Peral 也写了一篇文章 Saving time with Sourcery 来解释如何使用 Sourcery 节省测试时间。或者 John Sundell 的这篇 Mocking in Swift,它提供了有关如何制作mock的更多细节。

最后,感谢您阅读这篇文章。如果您觉得它对您有用或者您认为对某人有用,请分享它 😉。如果您有任何疑问或者改进意见,请随时在下面发表评论。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容